docs(maestro): design spike for in-app update notifications + opt-in auto-update (NAP-331)
ci / rust (push) Successful in 2m51s
ci / rust (push) Successful in 2m51s
Answers the six NAP-331 questions and proposes an 8-ticket impl breakdown. Grounds decisions in existing infra: DaemonInfo.version is already fanned out by the registry, [autoupdate] fits the sectioned config, and NAP-330's KillMode=process already gives restart-without-session-loss. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,314 @@
|
|||||||
|
# Maestro — In-app Update Notifications + Optional Auto-update
|
||||||
|
|
||||||
|
**Date**: 2026-06-18
|
||||||
|
**Status**: Design spike (NAP-331). Produces decisions, not implementation.
|
||||||
|
**Depends on**: NAP-330 (`KillMode=process` — restart without session loss). **Done.**
|
||||||
|
**Touches**: NAP-225 (installer / systemd unit), the "prebuilt binaries for thin hosts" idea.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Turn today's yolo-local update hack into a first-class, cross-host maestro
|
||||||
|
capability: every client surface ("a newer version is available, X→Y") and an
|
||||||
|
opt-in mechanism to apply the update without dropping sessions.
|
||||||
|
|
||||||
|
This is a design spike. It answers the six questions on NAP-331, then proposes
|
||||||
|
an implementation ticket breakdown. No code ships from this ticket beyond this
|
||||||
|
document.
|
||||||
|
|
||||||
|
## Prior art (the hack we're replacing)
|
||||||
|
|
||||||
|
A yolo-local prototype (out of repo, intentionally uncommitted): a gitea push
|
||||||
|
webhook → a tiny `--user` systemd listener on a private ip:port → worker that
|
||||||
|
hard-resets a dedicated checkout to `origin/main`, `cargo install`s, restarts
|
||||||
|
the daemon (stamping the last-built commit). Works, but it is per-host
|
||||||
|
shell+systemd with no in-app surface and no policy. We keep its shape (separate
|
||||||
|
supervised updater, dedicated checkout, systemd restart) and add: build-commit
|
||||||
|
stamping in the binary, a registry-mediated "latest seen" signal, client
|
||||||
|
surfaces, a config policy block, and a prebuilt-binary path for thin hosts.
|
||||||
|
|
||||||
|
## Key existing infrastructure we build on
|
||||||
|
|
||||||
|
Grounding the design in what already exists in the repo today:
|
||||||
|
|
||||||
|
- **`DaemonInfo.version: String`** (`maestro-protocol`) is *already* advertised
|
||||||
|
by each daemon in `Hello` and fanned out by the registry in
|
||||||
|
`RegistryEvent::Snapshot` / `RegistryEvent::DaemonAdded`. The registry already
|
||||||
|
knows every connected daemon's version. Cross-host version awareness is
|
||||||
|
therefore an *extension of an existing field*, not new plumbing.
|
||||||
|
- **`RegistryEvent` fan-out WS** (`/api/events`, `maestro-registry/src/events.rs`)
|
||||||
|
already server-pushes catalog/presence deltas to every client. A
|
||||||
|
"newest-version-seen" signal rides this same channel.
|
||||||
|
- **`MaestroConfigFile`** (`maestro-config`) is sectioned TOML
|
||||||
|
(`[shared]`, `[daemon]`, `[registry]`, `[web]`, `[tui]`, `[cli]`). An
|
||||||
|
`[autoupdate]` section drops in with the same `skip_serializing_if = is_empty`
|
||||||
|
pattern.
|
||||||
|
- **systemd unit** (`maestro-installer/src/systemd.rs`): `Type=simple`,
|
||||||
|
`ExecStart={exec} daemon`, `KillMode=process` (NAP-330), `Restart=on-failure`,
|
||||||
|
`RestartSec=5`. `systemctl restart maestro` already preserves tmux sessions.
|
||||||
|
- **Version is `0.1.0` workspace-wide, no git hash.** `version.workspace = true`;
|
||||||
|
the only consumer today is a `user_agent` string in `maestro-cli/src/http.rs`.
|
||||||
|
There is **no `build.rs`** in the umbrella crate yet. Stamping the commit is
|
||||||
|
net-new and is the hard prerequisite for everything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q1 — Version detection
|
||||||
|
|
||||||
|
**Decision: stamp the build commit at compile time and carry it as a structured
|
||||||
|
`BuildVersion`, with `origin/main` HEAD as the source of truth for "latest" in
|
||||||
|
the self-hosted (cargo-install) path, and a CI-published `manifest.json` for the
|
||||||
|
prebuilt path.**
|
||||||
|
|
||||||
|
### Stamping the binary
|
||||||
|
|
||||||
|
Add a `build.rs` to `crates/maestro/` that emits the git commit (and dirty flag)
|
||||||
|
as compile-time env vars. Two viable approaches:
|
||||||
|
|
||||||
|
- **`vergen`** (or `vergen-gix`): mature, emits `VERGEN_GIT_SHA`,
|
||||||
|
`VERGEN_GIT_COMMIT_TIMESTAMP`, dirty, etc. Heavier dep tree.
|
||||||
|
- **Hand-rolled `build.rs`** running `git rev-parse HEAD` + `git status
|
||||||
|
--porcelain`, with `cargo:rerun-if-changed=.git/HEAD`. Zero deps, ~20 lines.
|
||||||
|
|
||||||
|
Recommend the **hand-rolled build.rs** — the repo already favors minimal deps and
|
||||||
|
real-tool integration tests; we only need three values. Critically, the build
|
||||||
|
script must degrade gracefully: when built from a tarball / crates.io / a
|
||||||
|
checkout with no `.git` (thin host installing a prebuilt), fall back to
|
||||||
|
`option_env!` and emit `unknown`. Never fail the build for a missing git.
|
||||||
|
|
||||||
|
Expose a structured version everywhere `--version` is printed:
|
||||||
|
|
||||||
|
```
|
||||||
|
maestro 0.1.0 (a1b2c3d 2026-06-18, dirty)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `BuildVersion { semver, commit, commit_time, dirty }` type
|
||||||
|
(`maestro-protocol`, since it crosses the wire) and a `maestro version --json`
|
||||||
|
subcommand for machine consumers (the updater, tests).
|
||||||
|
|
||||||
|
`DaemonInfo.version` is currently a bare `String` (`"0.1.0"`). **Migrate it to
|
||||||
|
carry the commit** — either widen it to `BuildVersion` (clean, breaks wire compat
|
||||||
|
with older daemons) or keep the `String` and adopt the `"0.1.0 (a1b2c3d)"`
|
||||||
|
display form (compat-friendly, parse on read). Recommend **widening to a struct
|
||||||
|
with `#[serde(default)]`-friendly fields** so old daemons that send a bare semver
|
||||||
|
still deserialize (commit ⇒ `None`/`unknown`). This is the only wire change.
|
||||||
|
|
||||||
|
### Source of truth for "latest"
|
||||||
|
|
||||||
|
Two sources, matching the two apply mechanisms (Q3):
|
||||||
|
|
||||||
|
- **Self-hosted / cargo-install hosts** → `origin/main` HEAD commit. The updater
|
||||||
|
component (Q6) is the only thing that talks to git; it resolves
|
||||||
|
`git ls-remote origin main` (or reads from its dedicated checkout) to learn the
|
||||||
|
latest commit. "Newer" = `latest_commit != my_build_commit` **and** latest is a
|
||||||
|
descendant (avoid offering downgrades on a force-push; `git merge-base
|
||||||
|
--is-ancestor`).
|
||||||
|
- **Thin / prebuilt hosts** → a CI-published `manifest.json`
|
||||||
|
(`{ semver, commit, commit_time, artifacts: { "<target-triple>": { url, sha256 } } }`)
|
||||||
|
at a stable URL. The updater polls/fetches it; "newer" = manifest commit_time >
|
||||||
|
my commit_time.
|
||||||
|
|
||||||
|
Semver (`0.1.0`) alone is **not** sufficient — it is bumped rarely and the same
|
||||||
|
semver spans many commits. Commit identity is the real version key; semver stays
|
||||||
|
the human-facing coarse label.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q2 — Surfaces
|
||||||
|
|
||||||
|
**Decision: the registry computes a `latest_version` across all daemons it has
|
||||||
|
ever seen and broadcasts it on the existing `RegistryEvent` channel; every client
|
||||||
|
compares its local daemon's `BuildVersion` against that and renders a passive
|
||||||
|
"update available X→Y" affordance.**
|
||||||
|
|
||||||
|
### Registry as the version oracle
|
||||||
|
|
||||||
|
The registry already receives every daemon's version. Extend its state with
|
||||||
|
`latest_seen: BuildVersion` = max-by-commit-time across connected daemons,
|
||||||
|
optionally bumped by the external "latest" source (Q1) if the registry is
|
||||||
|
configured to poll it. Add to `RegistryEvent`:
|
||||||
|
|
||||||
|
- `RegistryEvent::Snapshot` gains a `latest_version: Option<BuildVersion>` field.
|
||||||
|
- A new `RegistryEvent::LatestVersion { version }` delta pushed when the max
|
||||||
|
advances (a daemon with a newer build connects, or the poll finds a new commit).
|
||||||
|
|
||||||
|
This means **a phone that only ever talks to the registry still learns that a
|
||||||
|
newer version exists** the moment any single host upgrades — exactly the
|
||||||
|
"broadcast the newest version it has seen" idea from the ticket. No client needs
|
||||||
|
to talk to git.
|
||||||
|
|
||||||
|
### Per-surface rendering
|
||||||
|
|
||||||
|
| Surface | Affordance | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| CLI `maestro list` | One-line footer: `update available: 0.1.0 (a1b2c3d) → 0.1.0 (e4f5a6b) on 2/3 hosts` | Only when at least one host is behind `latest`. `--quiet` / `MAESTRO_NO_UPDATE_NOTICE` suppresses. |
|
||||||
|
| TUI | Dismissable banner row / status-bar segment | Color-coded; click/hotkey opens a per-host update list. |
|
||||||
|
| Web | Header badge (dot + tooltip) | Subscribes to `RegistryEvent`; badge lists which hosts are behind. |
|
||||||
|
|
||||||
|
All three are **read-only notices** in the notify path. Applying is a separate,
|
||||||
|
explicit action (a CLI subcommand / a confirmed button), never a side effect of
|
||||||
|
viewing the list. Per-host granularity matters: in a federated fleet some hosts
|
||||||
|
upgrade before others, so the surface shows *which* hosts are behind, not a single
|
||||||
|
global flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q3 — Apply mechanism
|
||||||
|
|
||||||
|
**Decision: support both, selected per-host by a config/auto-detected
|
||||||
|
`method`. Default `cargo-install` where a toolchain exists, `prebuilt` on thin
|
||||||
|
hosts.**
|
||||||
|
|
||||||
|
- **`cargo-install` (build-on-host).** Mirrors the prototype: dedicated checkout
|
||||||
|
(NOT the user's working tree), `git fetch` + hard-reset to the target commit,
|
||||||
|
`cargo install --path crates/maestro --locked`, then restart. Needs a Rust
|
||||||
|
toolchain; ~tens of seconds; produces a binary matching the host exactly.
|
||||||
|
- **`prebuilt` (CI binaries).** Fetch the artifact for the host's target triple
|
||||||
|
from the Q1 manifest, verify `sha256`, atomically replace `~/.cargo/bin/maestro`
|
||||||
|
(write to temp + `rename`), then restart. Works on a Pi / LXC with no compiler.
|
||||||
|
Requires CI to cross-compile and publish per-triple artifacts + manifest (new CI
|
||||||
|
work, called out as its own ticket).
|
||||||
|
|
||||||
|
`method` resolution order: explicit `[autoupdate].method` → auto-detect (is
|
||||||
|
`cargo` on `PATH` and a source checkout present? → `cargo-install`, else
|
||||||
|
`prebuilt`). Both paths converge on the same **stamp-then-restart** epilogue:
|
||||||
|
record the newly-installed commit where the updater can read it back (so a failed
|
||||||
|
restart doesn't claim success), then `systemctl --user restart maestro`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q4 — Auto-run policy
|
||||||
|
|
||||||
|
**Decision: opt-in `[autoupdate]` config section, default `off`. Three modes,
|
||||||
|
three trigger styles.**
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[autoupdate]
|
||||||
|
mode = "notify-only" # off | notify-only | auto (default: off)
|
||||||
|
method = "cargo-install" # cargo-install | prebuilt | auto (default: auto)
|
||||||
|
trigger = "poll" # webhook | poll | schedule (default: poll)
|
||||||
|
source = "origin/main" # git ref, or a manifest URL for prebuilt
|
||||||
|
poll_interval = "15m" # for trigger = poll
|
||||||
|
window = "*-*-* 04:00:00" # for trigger = schedule (systemd OnCalendar)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`off`** — no detection, no notice, no apply. (Detection cost is near-zero, but
|
||||||
|
`off` honors users who want maestro to never phone-home about versions.)
|
||||||
|
- **`notify-only`** — detect + surface (Q2), never apply. The safe default to
|
||||||
|
*recommend* once stamping lands.
|
||||||
|
- **`auto`** — detect + apply via the updater (Q6), respecting `trigger`/`window`.
|
||||||
|
|
||||||
|
Fits `MaestroConfigFile` as a new section with the existing
|
||||||
|
`skip_serializing_if = AutoupdateSection::is_empty` pattern; lives logically near
|
||||||
|
`[daemon]` (the daemon is what gets restarted) but is consumed by the **updater**,
|
||||||
|
not the daemon process (Q6).
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- `webhook` — the prototype's model: gitea push → updater's tiny HTTP listener.
|
||||||
|
Lowest latency; needs an exposed port on the perimeter.
|
||||||
|
- `poll` — updater wakes on `poll_interval`, checks the Q1 source. No inbound
|
||||||
|
port. **Default** — simplest, works everywhere.
|
||||||
|
- `schedule` — systemd timer (`OnCalendar=window`); apply only inside a quiet
|
||||||
|
window. Best for `auto` mode so restarts don't interrupt active work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q5 — Restart safety
|
||||||
|
|
||||||
|
**Decision: rely on NAP-330's `KillMode=process` + `systemctl restart`; the
|
||||||
|
updater never kills the daemon directly. Add a pre-restart drain check.**
|
||||||
|
|
||||||
|
NAP-330 already shipped `KillMode=process` on the unit, so `systemctl --user
|
||||||
|
restart maestro` SIGTERMs only the daemon's main process — the tmux server and
|
||||||
|
its sessions (separate process tree) survive, and the fresh daemon re-adopts them
|
||||||
|
on startup. This is the load-bearing guarantee; the spike confirms it is already
|
||||||
|
in place.
|
||||||
|
|
||||||
|
Additions for safety:
|
||||||
|
- The updater calls `systemctl restart` (a supervised swap), **never** `kill`. The
|
||||||
|
binary is replaced on disk first; systemd execs the new one.
|
||||||
|
- **Atomic binary replace** (`prebuilt` path) so a half-written binary can't be
|
||||||
|
exec'd: write to `maestro.new`, `fsync`, `rename` over the target.
|
||||||
|
- **Re-adoption is already the daemon's normal startup behavior** (it lists
|
||||||
|
existing tmux sessions on boot). Verify post-restart the daemon re-appears in the
|
||||||
|
registry with the new `BuildVersion` within a timeout; if not, the updater logs a
|
||||||
|
failure (and, future, could roll back to the prior binary — out of scope here,
|
||||||
|
flagged as a follow-up).
|
||||||
|
- `auto` mode should prefer the `schedule` trigger / a quiet window so a restart
|
||||||
|
never lands mid-keystroke for an attached client. A restart is invisible to
|
||||||
|
persistence but *does* blip live WS streams (clients reconnect); doing it at
|
||||||
|
04:00 is kinder than at noon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q6 — Trust / security
|
||||||
|
|
||||||
|
**Decision: the self-modifying build+exec logic lives in a separate, minimal
|
||||||
|
`maestro-updater` component supervised by systemd — never inside the daemon
|
||||||
|
process.**
|
||||||
|
|
||||||
|
Rationale: a daemon that can `git reset`, `cargo install`, and exec arbitrary
|
||||||
|
freshly-built code is a remote-code-execution surface. The daemon is the
|
||||||
|
network-facing, multi-client process; it must stay incapable of rewriting its own
|
||||||
|
binary. A supervised restart via systemd is needed anyway (the running process
|
||||||
|
can't replace the file it's executing cleanly), so the separation is free.
|
||||||
|
|
||||||
|
Shape:
|
||||||
|
- **`maestro-updater`** — a small binary (new crate `crates/maestro-updater/`, or a
|
||||||
|
feature-gated subcommand `maestro update`). Owns the dedicated checkout, the
|
||||||
|
fetch/build/fetch-artifact, the atomic replace, and `systemctl restart`. Run as
|
||||||
|
its own `--user` systemd unit/timer, NOT a child of the daemon.
|
||||||
|
- **Trust boundary on the source.** `cargo-install` path trusts whatever
|
||||||
|
`origin/main` resolves to — acceptable only because the operator controls the
|
||||||
|
repo and the perimeter (consistent with maestro's network-agnostic threat model:
|
||||||
|
the perimeter is the VPN/LAN). `prebuilt` path **must** verify `sha256` from the
|
||||||
|
manifest against the downloaded artifact, and the manifest should be fetched over
|
||||||
|
TLS from a trusted origin. Document that turning on `auto` = trusting that
|
||||||
|
source to run code on the host.
|
||||||
|
- **No webhook auth shortcuts.** If `trigger = webhook`, the listener must
|
||||||
|
authenticate the caller (shared secret / HMAC of the gitea payload), bind to the
|
||||||
|
perimeter interface, and only ever *trigger* an update of the configured source —
|
||||||
|
it must not accept a commit/url from the request body (that would let any caller
|
||||||
|
pick the code to run).
|
||||||
|
- **Least privilege.** The updater needs write to `~/.cargo/bin` and the dedicated
|
||||||
|
checkout, and `systemctl --user restart` rights. Nothing more. It does not need
|
||||||
|
the daemon's token or registry credentials for the apply itself (it may read the
|
||||||
|
config for policy only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed implementation ticket breakdown
|
||||||
|
|
||||||
|
In dependency order (each is a separate impl ticket spun off from this spike):
|
||||||
|
|
||||||
|
1. **Build-commit stamping.** `build.rs` in `crates/maestro/`, `BuildVersion`
|
||||||
|
type, `--version` long form + `maestro version --json`. Graceful no-git
|
||||||
|
fallback. *(Prerequisite for everything.)*
|
||||||
|
2. **Wire the commit through `DaemonInfo`.** Widen `DaemonInfo.version` to carry
|
||||||
|
the commit with backward-compatible deserialization; daemon advertises real
|
||||||
|
`BuildVersion` in `Hello`.
|
||||||
|
3. **Registry "latest seen" + `RegistryEvent`.** Compute `latest_version`,
|
||||||
|
add `Snapshot.latest_version` + `RegistryEvent::LatestVersion`.
|
||||||
|
4. **Client surfaces.** CLI `list` footer, TUI banner, web header badge. (Can be
|
||||||
|
three sub-tickets; share the registry signal from #3.)
|
||||||
|
5. **`[autoupdate]` config section.** Schema + parsing + defaults in
|
||||||
|
`maestro-config`. Notify-only behavior end-to-end (no apply yet).
|
||||||
|
6. **`maestro-updater` component — cargo-install path.** Dedicated checkout,
|
||||||
|
fetch/reset/install, atomic stamp, `systemctl restart`. systemd unit/timer via
|
||||||
|
the installer (NAP-225).
|
||||||
|
7. **Prebuilt path + CI manifest.** CI cross-compiles per-triple artifacts,
|
||||||
|
publishes `manifest.json` + `sha256`; updater `prebuilt` method with
|
||||||
|
verification.
|
||||||
|
8. **`auto` mode + triggers.** Wire `webhook` / `poll` / `schedule` to the
|
||||||
|
updater; quiet-window scheduling; post-restart version-confirm check.
|
||||||
|
|
||||||
|
#5 and #6 can proceed in parallel after #1–#4. #7 is the heaviest (new CI) and
|
||||||
|
gates only thin-host auto-update; #1–#6 deliver the full cross-host *notify* story
|
||||||
|
plus auto-update on build-capable hosts.
|
||||||
|
|
||||||
|
## Scope boundary
|
||||||
|
|
||||||
|
- **In scope (this spike):** the decisions above + the ticket breakdown.
|
||||||
|
- **Out of scope:** rollback-on-failed-restart (flagged in Q5), multi-arch CI
|
||||||
|
setup details (lands in #7), and any auth design for a hosted/public manifest
|
||||||
|
CDN — maestro stays network-agnostic; the operator's perimeter is the trust
|
||||||
|
boundary (spec §Auth).
|
||||||
Reference in New Issue
Block a user