docs(maestro): spec — build maestro-web SPA bundle at compile time

build.rs runs pnpm install+build before include_dir! embeds web/dist, so
any install method (cargo install --git, --path, wizard) ships a real
bundle instead of the placeholder. Hard-fail when pnpm/node missing;
wizard prompts abort/skip; CI gains the node+pnpm toolchain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 19:24:18 +00:00
co-authored by Claude Opus 4.8
parent d0f8997a78
commit 63217d7851
@@ -0,0 +1,131 @@
# maestro-web: build the SPA bundle at compile time
**Date:** 2026-05-28
**Status:** Approved (design)
## Problem
`maestro-web` embeds the SPA bundle at **compile time** via
`include_dir!("$CARGO_MANIFEST_DIR/../../web/dist")` (`crates/maestro-web/src/static_bundle.rs`).
Only `web/dist/.gitkeep` is checked into git — the real bundle is gitignored and
must be produced by `pnpm -C web build`.
The installer's default source is `cargo install --git` (`maestro-installer`
`build_source`). A fresh git clone has `web/dist` containing *only* `.gitkeep`,
so `include_dir!` embeds an empty bundle and the running binary serves the
"bundle not built" placeholder. **Every git-based install hits this** — it is
guaranteed, not bad luck.
Secondary problems found while diagnosing:
- The placeholder (and `Justfile`'s `web:` target) tell the operator to run
`cargo build -p maestro-web --release`, which writes `target/release/...`, but
the systemd unit runs `~/.cargo/bin/maestro-web` (from `cargo install`). So
following the placeholder's own advice never updates the running service.
- `include_dir!` does not emit `cargo:rerun-if-changed` for the embedded
directory. Once `maestro-web` is compiled with an empty `web/dist`, rebuilding
after `pnpm build` does **not** re-embed the bundle — cargo sees no source
change and reuses the stale object.
## Goal
A compiled `maestro-web` binary always embeds a real SPA bundle, regardless of
install method (`cargo install --git`, `--path`, or the wizard). No placeholder
for installed users.
## Design
### 1. `crates/maestro-web/build.rs` (new)
Runs during every compile of the crate that cargo deems necessary (gated by the
`rerun-if-changed` set below).
- Locate `web/` relative to `CARGO_MANIFEST_DIR` (`../../web`).
- Resolve `pnpm` on `PATH`, and confirm `node` is present.
**If either is missing → fail the build** (`panic!` from `build.rs`, which
cargo surfaces as a build error) with an actionable message, e.g.:
> `maestro-web requires pnpm + node to build its embedded SPA bundle. Install
> them (https://pnpm.io) or use a prebuilt binary.`
- Run, in `web/`:
1. `pnpm install --frozen-lockfile` (a fresh `--git` clone has no
`node_modules`, so install is required).
2. `pnpm build`.
If either exits non-zero, fail the build and surface stderr.
- Emit `cargo:rerun-if-changed=` for the frontend inputs so the bundle rebuilds
exactly when those change (and not on every Rust compile):
- `../../web/src`
- `../../web/index.html`
- `../../web/package.json`
- `../../web/pnpm-lock.yaml`
- `../../web/vite.config.ts`
This also fixes the staleness bug: changing the frontend now reliably
re-embeds.
### 2. `crates/maestro-web/src/static_bundle.rs` — unchanged
`include_dir!` still embeds `web/dist`, now guaranteed populated by `build.rs`.
The placeholder path stays as harmless defense-in-depth; in a successful build
it is unreachable. Existing tests remain valid.
### 3. Installer wizard — `handle_web` (`crates/maestro-installer/src/main.rs`)
The web step is already opt-in (interactive prompt, or silent refresh when a
unit already exists). After the user opts in, **preflight pnpm + node** before
`install_crate(WEB_CRATE)`:
- **Interactive, prerequisite missing:** prompt
> `maestro-web needs pnpm + node, which aren't installed. [A]bort install or [S]kip web UI?`
- *Abort* → exit the installer non-zero.
- *Skip* → continue without the web binary; daemon/registry work already done
is left intact. Return `Ok(false)` from `handle_web`.
- **Non-interactive refresh path** (existing unit, no TTY): cannot prompt —
print a clear warning that the web refresh is skipped because pnpm/node are
missing, and leave the existing unit in place. Return `Ok(false)`.
- **Both present:** proceed exactly as today. The existing `cargo install --git`
now Just Works because `build.rs` builds the bundle inside the temp clone.
This keeps the daemon/registry install unaffected by a missing frontend
toolchain, and never lets a raw `build.rs` panic leak to the user — the wizard
catches the missing-prereq case first.
### 4. CI — `.gitea/workflows/ci.yml`
`clippy` and `test` compile `maestro-web`, which now requires the frontend
toolchain. Add a step (before `clippy`) that installs Node.js + pnpm and runs
`pnpm -C web install --frozen-lockfile`, so the workspace builds on the runner.
Node/pnpm install goes in the existing "Install … build deps" step or a new
adjacent step; `corepack enable` (or a pinned pnpm install) provides `pnpm`.
### 5. `Justfile` — unchanged
The `web:` target stays as a `--release` convenience and as the documented
local build command. With `build.rs` in place its `pnpm build` step is partly
redundant but harmless.
## Consequences accepted
- The `maestro-web` crate cannot be compiled without node + pnpm. This is
intentional (hard fail). It affects: workspace CI (addressed in §4), any
Rust-only contributor building the full workspace, and `cargo install --git`
on a machine without node.
- `cargo install --git` becomes slower (runs `pnpm install` + `pnpm build`), but
produces a correct binary — the whole point.
## Out of scope
- Shipping a prebuilt release binary (considered, deferred — the build.rs +
wizard path covers the reported problem).
- Changing the data-plane / proxy behavior of `maestro-web`.
## Testing
- `build.rs` is exercised implicitly by every compile; a green
`cargo build -p maestro-web` on a clean checkout (no prior `pnpm build`) that
yields a binary whose `/` serves the real `index.html` is the acceptance
signal. An integration check can assert the served root contains the SPA
mount (`<div id="app">`) rather than the placeholder string.
- Installer: unit-test the preflight decision (missing-prereq → abort/skip
branch selection) without invoking cargo, following existing
`maestro-installer` test patterns.
- CI itself is the regression test for the toolchain requirement.