Skip to main content

Module web

Module web 

Source
Expand description

The web UI: magi’s queue and run history, readable from a phone.

The terminal is the wrong surface for the two things an operator actually does between runs — file a task and check whether the last competition landed. Both happen away from the desk, so they get an HTTP surface: a handful of JSON routes and three embedded files.

§One binary

index.html, app.css and app.js are compiled in with include_str!. There is no --assets-dir and no filesystem fallback, because a UI that reads its own front end from disk breaks the moment the binary is copied somewhere else — which is exactly what cargo install magi-cli does. No JS toolchain, no CDN, no remote font: everything the phone needs arrives from this process.

§No authentication

There is none, deliberately, and the startup log says so. The tailnet is the security boundary: --bind auto resolves to this machine’s Tailscale address, so the UI is reachable from the operator’s own devices and from nothing else. Anyone who can open the URL can file and hold tasks, which is why binding to 0.0.0.0 is not offered and why the fallback when Tailscale is missing is loopback rather than every interface.

§Change notification

A phone must not poll a full run list on a mobile link. GET /api/events is a server-sent stream carrying nothing but two revision numbers — the newest modification time in the queue and under the runs directory — so the client refetches only what moved. The browser’s own SSE reconnection covers a sleeping phone; there is no session to lose.

§Reading state must never take the server down

A corrupt run.json is skipped in the list and explained with a 500 on the detail route. No handler unwraps a filesystem or parse result: a single bad file left by a killed run would otherwise turn the whole history into a blank page.

§Agent-authored HTML, rendered anyway

Everything else here refuses to put API data into the document: app.js builds nodes and sets textContent, and even an href from a run record is laundered first. A confirmation panel breaks that rule on purpose - an agent asking the owner to approve a merge needs a diff and a table, not one line of prose - and the only reason it is acceptable is that the panel is never part of this document.

It is served by [question_panel] and [question_asset] and rendered in an <iframe sandbox> carrying no tokens: no allow-scripts, no allow-same-origin. So no script in a panel runs, and the frame cannot reach the parent document, the cookie jar or localStorage. On top of that both routes send [PANEL_CSP], which denies every network destination, so a panel cannot phone home through a remote image or a beacon either - the two things it may load, images and inline CSS, are the two things free formatting actually needs. Assets come from the question’s own directory and never from the network, and their content types come from a closed whitelist, so an agent cannot get markup rendered outside the frame by naming a file .html.

§An interview is not a filesystem read

Every other route here is disk work, which is why [blocking] exists. POST /api/chats/{id}/say is the exception: it spawns an agent CLI and waits tens of seconds for a sentence. It is a plain await holding no lock and no executor thread, and concurrent turns on one chat are refused rather than queued - see Ui::begin_turn.

§The loop runs here

magi web runs the queue loop in this process, started and stopped from /api/loop. That is the point of the whole surface: a task filed from a phone with nobody around to type magi serve is a task that sits in the queue until someone walks back to the machine.

It is a tokio task holding a daemon::Stop, not a child process. There is no pid file of this module’s own and nothing to supervise - a child would need reaping, a second copy of the daemon’s retry policy, and a story for what happens when magi web dies with the loop still running. <home>/daemon.json, which the loop itself writes, stays the only cross-process signal, and it is how this process notices that the operator’s own magi serve already owns the loop and refuses to start a second one that would fight it for claims.

Stopping is cooperative and therefore not instant. A run in flight is finished first, for the reason daemon::serve gives: killing the graph mid-node leaves worktrees, branches and agent sessions behind and throws away every agent call already paid for. POST /api/loop sets the flag and answers immediately rather than waiting, because the wait is measured in tens of minutes and the operator is holding a phone.

Structs§

Opts
How to serve.
Ui
Everything the handlers touch.

Enums§

Bind
Which address to listen on.

Constants§

DEFAULT_PORT
Default port. Chosen high and memorable; nothing else in the fleet uses it.

Functions§

resolve_bind
Resolve --bind to an address, plus a warning when the answer is not what the operator asked for.
runs_unreadable
Runs on disk under runs whose state this build cannot parse - almost always a schema bump, occasionally a run killed mid-write.
serve
Serve the UI until Ctrl-C, finishing a run the loop has in flight.