* DaemonEntry stores the daemon's most recent perimeter_id / private_addr
from heartbeats, with `update_perimeter` and `perimeter_for` accessors.
* issue_pair_attach_jwt mints the iss/sub/aud/sid/exp pair claim shape
(60s TTL) — verified by the target daemon.
* Registry::resolve_attach assembles AttachCandidates: T1 stays None
(NAP-235 canceled), T2 carries the target's perimeter when known, T3
stays None (NAP-238 pending), plus the pair JWT.
* WS handler routes the new DaemonOutbound::ResolveAttach to the new
method and persists perimeter info on every Heartbeat.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New [daemon.policy] section with allow_inbound: Vec<String>. Default empty
(deny all peer attaches). The daemon plumbs it through DaemonConfig.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spins up registry + alpha + beta, creates a session on beta, verifies
alpha's `/api/sessions` and `/api/daemons` reflect the peer state within
5 s of the create round-tripping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New module crates/maestro-daemon/src/catalog_mirror.rs: in-memory mirror of
the global catalog (peer daemons + their sessions), updated from the
`DaemonInbound::CatalogEvent` frames the registry now fans out.
`local_api` consumes it: `GET /api/sessions` returns the union of the
daemon's own sessions and the mirrored peers; `GET /api/daemons` returns
self + connected peers. The web UI sees the same catalog from any home
daemon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each daemon connection now also subscribes to the registry's event broadcast.
A per-connection task pumps peer-filtered events through the existing
outbound mpsc as `CatalogEvent` frames; the daemon side mirrors them
locally (NAP-246).
Two pre-existing race fixes that NAP-246's extra traffic exposed:
* `create_session` / `kill_session` now eagerly mirror Ok results into
state.sessions (via the same `add_session` / `remove_session` calls the
daemon's async `SessionAdded` / `SessionRemoved` will hit later — both
are idempotent). HTTP callers no longer see a transient empty catalog
immediately after a successful POST.
* Catalog frames flow through the same select! arm as RPC frames so the
WS sink+stream coupling axum imposes doesn't stall the inbound reader.
Updates daemon_ws.rs tests to skip CatalogEvent frames when asserting on
RPC frames.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New control-link message wrapping a registry event for daemons that want a
local catalog mirror. `RegistryEvent::for_peer(self_id)` strips events that
concern the recipient itself — daemons mirror peer state, not self.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser now opens ws://{host}/api/attach/:id directly — maestro-web
reverse-proxies it to the home daemon's broker, which short-circuits to
the local PTY (NAP-243) or, once NAP-247 lands, brokers to a peer over T2.
Drops the old getConnectInfo + daemon_url path: the browser no longer
knows or cares which daemon owns a session. ConnectInfo type and the
helper are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New crate crates/maestro-web/: axum server that
* embeds the web/dist SPA bundle via include_dir! (graceful placeholder when
pnpm build hasn't run yet — keeps fresh checkouts compiling)
* reverse-proxies /api/* HTTP to a co-located daemon (default
http://127.0.0.1:7070) via reqwest, stripping hop-by-hop headers
* reverse-proxies /api/attach/:id and /api/events WebSockets via
tokio_tungstenite::connect_async + a tokio task pumping both directions
* reads bind_addr / daemon_url / dev_proxy_target from the new [web]
config section; env-var overrides MAESTRO_WEB_BIND_ADDR / _DAEMON_URL /
_DEV_PROXY_TARGET
Integration tests spin up a mock daemon + maestro-web pair on random ports
and verify HTTP forward, SPA fallback, WS echo, and 502 on unreachable
daemon.
Adds a top-level Justfile (`just web` = pnpm install + pnpm build + cargo
build), crates/maestro-web/README.md (config + trust model + dev workflow),
and a docs/INSTALL.md section on optional web UI install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WebSection holds bind_addr, daemon_url, and dev_proxy_target — all optional
so a partial file is still valid. Bumps the workspace deps with include_dir,
mime_guess, bytes; adds `stream` to reqwest features for proxying bodies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New module crates/maestro-daemon/src/local_api.rs serving:
* GET /api/sessions — own sessions
* GET /api/daemons — self only (v1; peer mirror is NAP-246)
* GET /api/web/info — daemon identity (browser header data)
* POST /api/sessions — create on self; 501 for cross-daemon (NAP-247)
* DELETE /api/sessions/:id — kill on self; 404 for unknown
* WS /api/attach/:id — delegate to existing PTY subscribe when self-owned,
404 otherwise (cross-daemon broker is NAP-247)
The new routes share bind_addr with the legacy /sessions/:id/stream WS
endpoint, which keeps working unchanged. The shared serve loop is exposed
as attach::serve_local so the broker WS can reuse the PTY pump.
Trust model documented at the top of the module: no JWT, no cookie on
the new routes — same trust as tmux being one-user-wide. Operators who
bind a perimeter address opt into one-segment-wide trust.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RegistryClient gains a with_perimeter() setter that captures both fields
from DaemonConfig and ships them in every Heartbeat. NAP-247 (broker T2)
will consume these on the registry side to populate AttachCandidates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new optional fields in [daemon]: perimeter_id (e.g. "wg-naps62") and
private_addr (e.g. "10.0.0.7:7070"). Env overrides via
MAESTRO_DAEMON_PERIMETER_ID and MAESTRO_DAEMON_PRIVATE_ADDR. Both flow
through DaemonConfig but the daemon doesn't act on them yet — heartbeat
wiring lands next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iss/sub/aud/sid/exp claims for the broker-mediated attach flow. "Pair-scoped"
means the credential identifies which home daemon (sub) may attach to which
target daemon (aud) for which session (sid) — a stolen token only works
between the two daemons it was issued for.
Existing JwtClaims stays as a compat path; NAP-243 wires the new claims into
the daemon's verifier and the registry's issuer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Convert DaemonOutbound::Heartbeat from unit to struct variant with two
optional fields advertising the daemon's T2 reachability. Old payloads
({"type":"Heartbeat"} with no fields) still parse — both fields default
to None — and a new daemon that doesn't populate them serializes back to
the same minimal shape.
Update the daemon's heartbeat ticker and the registry's match arm to the
new shape. Population from daemon config + registry persistence lands
with the broker logic in NAP-243.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the wire types for broker-mediated attach: PerimeterCandidate (T2 hint),
RelayCandidate (T3 marker, shape TBD via NAP-238), and AttachCandidates as
the registry's reply to ResolveAttach.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared section is the implicit global scope, so its fields use
the bare `MAESTRO_<FIELD>` env-var spelling. Only `[daemon]` and
`[registry]` sections get section-prefixed env vars.
Renames:
- MAESTRO_SHARED_TOKEN → MAESTRO_TOKEN
- MAESTRO_SHARED_REGISTRY_URL → MAESTRO_REGISTRY_URL
`MAESTRO_DAEMON_*` and `MAESTRO_REGISTRY_BIND_ADDR` are unchanged.
This restores the canonical spec name (`MAESTRO_TOKEN`) that operators
already use in their deployment scripts — NAP-233 had renamed it to
`MAESTRO_SHARED_TOKEN`, which silently broke daemon auth against
existing registry deployments still setting `MAESTRO_TOKEN` in env.
Constants renamed alongside: `ENV_SHARED_TOKEN` → `ENV_TOKEN`,
`ENV_SHARED_REGISTRY_URL` → `ENV_REGISTRY_URL`. `EnvOverrides` fields
shed their `shared_` prefix to match.
Touches: daemon/cli/registry crates, e2e harness, design spec.
- Installer: check for a `[daemon]` section in the unified file (rather
than the existence of the now-defunct `~/.config/maestrod/config.toml`)
to decide whether to skip the configure prompt on re-runs. The
configure prompt + maestrod_config_path() helper now reference
`~/.config/maestro/config.toml`.
- docs/INSTALL.md: mention the unified file and the single-TOML shape.
- spec: update §Registry / §Daemon / §Auth to call out
`[registry].bind_addr`, `[daemon].bind_addr`, `[shared].registry_url`,
and `[shared].token` instead of the old per-app files.
Registry now reads `[shared].token` and `[registry].bind_addr` from the
unified file via maestro-config. First-run token generation does a
read-modify-write so `[shared]` / `[cli]` / `[daemon]` sections that
already exist on a co-located host stay intact.
Env-var renames:
- MAESTRO_TOKEN → MAESTRO_SHARED_TOKEN
- MAESTRO_REGISTRY_CONFIG → MAESTRO_CONFIG
`MAESTRO_REGISTRY_BIND_ADDR` is unchanged. The startup banner now points
the operator at `MAESTRO_SHARED_TOKEN` when announcing a freshly
generated token.
e2e harness updated to use the new env var; auth.rs doc-comment updated
to reflect the new env-var name.
Daemon now loads `[shared].registry_url`, `[shared].token`, and the
`[daemon]` section from the unified file via maestro-config. The
registry-WS endpoint is derived from `[shared].registry_url` (scheme
swap + `/daemon`), so operators configure a single base URL instead of
duplicating the daemon-specific endpoint.
Env-var renames (all per the new `MAESTRO_<SECTION>_<FIELD>` convention):
- MAESTROD_REGISTRY_URL → MAESTRO_SHARED_REGISTRY_URL (and the value is
now an http(s) base, not the ws daemon endpoint)
- MAESTROD_DAEMON_URL → MAESTRO_DAEMON_URL
- MAESTROD_DAEMON_ID → MAESTRO_DAEMON_ID
- MAESTROD_BIND_ADDR → MAESTRO_DAEMON_BIND_ADDR
- MAESTROD_CONFIG → MAESTRO_CONFIG
- MAESTRO_TOKEN → MAESTRO_SHARED_TOKEN
`maestrod configure` now writes the unified file with read-modify-write
so any existing `[cli]` / `[registry]` sections on the same host are
preserved. The interactive prompts reflect the new shape: registry URL
is asked for as a base URL, and the token prompt names the new env var.
Missing-file behaviour is hard cutover: if the config doesn't exist and
not every required field comes from env, the daemon errors with a
message pointing at `maestrod configure` and explicitly noting that the
legacy paths (~/.config/maestrod/config.toml) are no longer read.
e2e harness updated to match: `Daemon::spawn` takes the registry's HTTP
base, and the spawned subprocess sees the renamed env vars.
CLI now reads `[shared].registry_url` and `[cli].cookie` from the unified
file via maestro-config instead of `~/.config/maestro/cli.toml`. The
two persistence paths are now distinct:
- `maestro set-registry <url>` → writes `[shared].registry_url`
- captured Set-Cookie on first contact → writes `[cli].cookie`
Both go through read-modify-write so they preserve any `[daemon]` /
`[registry]` content already in the file.
Env-var renames on the CLI flag layer:
- `--registry-url` reads `MAESTRO_SHARED_REGISTRY_URL` (was `MAESTRO_REGISTRY_URL`)
- `--config` reads `MAESTRO_CONFIG` (was `MAESTRO_CLI_CONFIG`)
Default config path is now `~/.config/maestro/config.toml`.
Introduces the shared on-disk schema that all binaries (CLI, daemon,
registry) will load from `~/.config/maestro/config.toml`. Sections:
[shared] registry_url, token — consumed by 2+ binaries
[cli] cookie
[daemon] daemon_id, hostname, daemon_url, bind_addr, state_dir, worktree_root
[registry] bind_addr
Exposes:
- MaestroConfigFile { shared, cli, daemon, registry } with strict load,
load-or-default, atomic save, and per-section empty-table suppression.
- default_config_path() / resolve_config_path() honoring MAESTRO_CONFIG.
- derive_daemon_ws_url() for the daemon's scheme-swap + /daemon suffix
off the shared http(s):// base URL.
- ConfigError::MissingFile with a hard-cutover message pointing the user
at `maestrod configure` (legacy paths are no longer read).
Consumers wired in follow-up commits.
Earlier today I picked option 1 from NAP-231's open question (run
`maestro-registry` locally on a different port). That was wrong: the
user-facing framing is "local web UI", and option 2 — a thin front-end
that serves the web bundle and reverse-proxies `/api/*` to whatever
registry the daemon is already registered with — is what actually
delivers that.
What the new crate does:
- `crates/maestro-web` ships a single binary `maestro-web`.
- It embeds the web bundle the same way `maestro-registry` does
(rust-embed against `web/dist/`).
- Mounts the bundle at `/` and a reverse proxy at `/api/*` (catch-all).
- HTTP proxy via `reqwest` — forwards method, headers (minus
hop-by-hop), body, and passes upstream response headers (including
`Set-Cookie`) back to the browser.
- WS proxy upgrades both sides and shuttles frames with
`tokio::select!`. Cookies on the initial `WebSocket` upgrade headers
are forwarded so the registry sees the same browser session.
- Reads `MAESTRO_REGISTRY` (upstream URL) and `MAESTRO_WEB_BIND_ADDR`
(default `127.0.0.1:8888`) from env.
- An integration test (`tests/proxy.rs`) stands up a mock upstream,
proxies an HTTP request with cookies through, and round-trips a text
WebSocket frame to verify the WS path.
Installer changes:
- Wizard now `cargo install`s `maestro-web` (not `maestro-registry`)
when the user opts into the web service.
- Unit template switched: `ExecStart=%h/.cargo/bin/maestro-web` plus
`Environment=MAESTRO_REGISTRY=<upstream>` +
`Environment=MAESTRO_WEB_BIND_ADDR=<bind>`.
- New `--web-registry <URL>` flag. Default in interactive mode: read
`registry_url` straight out of `~/.config/maestrod/config.toml`, so
the proxy lines up with what the daemon is using without further
prompting. Non-interactive runs without either of those error out
rather than guess.
Docs:
- `docs/INSTALL.md` updated unit body + flag table.
- `CLAUDE.md` repo-layout block now lists `maestro-web`.
148 tests pass workspace-wide (9 new in maestro-web, including the
proxy integration test; 15 in installer).
When a session terminates (the user runs `exit`, tmux is killed, or
`maestro kill` is called) the daemon's per-session broadcast channel
closes and `serve_attach` breaks out of its loop. The socket was then
dropped without a Close frame, so the client's `stream.next()` would
hang and the terminal stayed stuck in raw mode.
- daemon: send `Message::Close(None)` before returning from
`serve_attach` whenever the session itself ended (broadcast closed,
or session was killed between auth and subscribe).
- client: track remote-close, restore the TTY first, then print
`[maestro] session ended` so the message lands in cooked mode.
- tests: cover the kill path and the liveness-watcher reap path on the
daemon side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the daemon-unit step, the wizard now offers a second user unit
(`maestro-web.service`) that runs `maestro-registry` on 127.0.0.1:8888
so the web UI is reachable in a browser locally.
Decisions:
- The unit runs `maestro-registry` directly — the web bundle is already
embedded in that binary, so a separate `maestro-web` binary would be
pure cost. `Description=` and the unit filename make the user-facing
framing clear.
- `Wants=maestrod.service` + `After=` (not `Requires=`): the registry
process doesn't actually need the daemon to start; the web UI just
has nothing to show without one. Soft dependency expresses that.
- Default loopback bind is `127.0.0.1:8888` to make it obvious which
port is the local UI vs a separately deployed registry on `:8080`.
Wizard surface:
- New prompt (default *no*) after the daemon-systemd step.
- New flags: `--no-web`, `--web-bind <addr>`, `--web-exec-start <path>`.
- Skipped automatically when systemd is absent or `--no-systemd` is set.
- The re-run/update path mirrors the daemon flow: refresh the unit,
daemon-reload, restart instead of start.
- `maestro-registry` is only `cargo install`ed when the user opts in,
keeping the no-web install footprint unchanged.
docs/INSTALL.md gets the new flags + a full sample unit body.
New `crates/maestro-e2e` exercises the full headless stack — registry,
two daemons, real HTTP+WS — as actual subprocesses.
Harness pieces:
- `Registry::spawn` and `Daemon::spawn` launch the real built binaries
with `env_clear()` plus an isolated `$HOME`, `XDG_CONFIG_HOME`,
`XDG_STATE_HOME`, and (for daemons) `TMUX_TMPDIR`. Drop guards kill
the child and clean the temp dirs.
- `Daemon::stop` + `Daemon::restart` swap the process without losing
the state dirs, so the recovery test exercises a real tmux
re-adoption.
- A `build::workspace_target_bin` helper re-invokes cargo from the
test process to build `maestrod` / `maestro-registry` on demand
(cargo only ships libs as path-dep of e2e by default).
- `TestClient` wraps reqwest + tokio-tungstenite for the public
HTTP+WS surface — list/spawn/kill/connect-info/attach + events WS.
Scenarios covered (`tests/e2e.rs`):
- list sessions from two daemons through one registry
- multi-attach: two WS clients see the same PTY output
- worktree with `cleanup_on_kill: true` is removed after kill
- daemon restart: catalog drops then repopulates from `recover()`
- concurrent spawns all get distinct ids
All five pass in ~0.7s in parallel; cleanup confirmed (no leftover
processes or temp dirs).
The daemon never noticed when a session's command exited and tmux tore
the session down — the in-memory `SessionManager` map plus the registry
catalog kept showing it as `Running`, and a follow-up `maestro attach`
would hang on an empty broadcast.
Adds a per-host background task that polls `tmux list-sessions` every
2s, diffs against the in-memory set, and reaps anything we still track
but tmux has lost: drops the record, deletes the sidecar + FIFO,
cleans up its worktree if requested, and emits `SessionEvent::Removed`
so the registry purges its catalog.
The task holds a `Weak<Inner>` so it exits cleanly once the last
`SessionManager` clone is dropped. Skips the tmux subprocess when the
in-memory map is empty.
`web/vite.config.ts` was hard-coded to proxy `/api` at
`http://127.0.0.1:8080`, so pointing the dev UI at a remote registry meant
hand-editing the file. Read `MAESTRO_REGISTRY` from the environment via
`loadEnv` instead, with the local default preserved. Accept either the HTTP
base URL (`https://…`) or the daemon-facing URL (`wss://…/daemon`) — the
latter is rewritten to `https://…` and the `/daemon` suffix stripped, since
the proxy needs the HTTP origin. Flips `changeOrigin: true` for HTTPS
upstreams so the proxied Host matches the cert.
Documented under `web/README.md` § Dev.
The wizard now treats existing state as "we're updating, not installing":
- If `~/.config/maestrod/config.toml` exists, skip the configure prompt with
a clear notice. Sets `configured = true` so the systemd start logic still
makes sense.
- If the systemd user unit already exists, refresh the file in place, skip
the enable prompt (it's either already enabled or the user said no last
time), and ask to `restart` instead of `start` — so the new binary
actually takes effect.
Adds `SystemctlAction::Restart` and a test for it. Same `./install.sh` works
for both first install and subsequent updates; no `--update` flag.
Refs NAP-225.
`curl | sh` only works for public repos — gitea hides private repos with a
404 to unauthenticated clients. Lead with downloading `install.sh` via the
signed-in web UI (or `git show origin/main:install.sh`) and running it
locally. Note that the inner `cargo install --git` still uses whatever git
credentials the host already has, and document `MAESTRO_REPO_URL=ssh://...`
for SSH-only setups.
Refs NAP-225.
Single command for both first install and subsequent updates:
curl -sSf https://git.naps.pt/naps62/maestro/raw/branch/main/install.sh | sh
Builds `maestro-installer` into a temp `--root`, runs the wizard, cleans up —
so only `maestrod` and `maestro` persist in `~/.cargo/bin`. Re-running updates
in place via cargo install's overwrite semantics. Wizard flags pass through
with `sh -s --`. Docs updated to lead with the one-liner.
Refs NAP-225.
New `maestro-installer` crate exposing the `maestro-install` binary. Bootstrap
flow: `cargo install --git ... maestro-installer && maestro-install` runs
`cargo install` for `maestro-daemon` and `maestro-cli`, optionally drives
`maestrod configure`, and on systemd hosts writes a user unit at
`~/.config/systemd/user/maestrod.service` plus reload/enable/start. Pure
builders (cargo install args, systemctl args, unit body) live in the lib and
are unit-tested; main.rs is the prompt glue.
Refs NAP-225.
The Dockerfile's `web-builder` stage (NAP-218) uses Node 20 + corepack.
Without a `packageManager` field, corepack defaulted to pnpm@latest,
which is now 11.3.0 — that release requires Node ≥22.13 and imports
`node:sqlite`, so it dies on Node 20 with
`ERR_UNKNOWN_BUILTIN_MODULE: node:sqlite`. Dokploy builds were failing
in the `pnpm install` step.
Pin pnpm@9.15.4 (latest 9.x, fully Node-20 compatible). No code change
to the web bundle itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The daemon's outbound registry client uses `connect_async`, which
errors with "TLS support not compiled in" against `wss://` URLs unless
the crate is built with a TLS feature. CLI got `rustls-tls-webpki-roots`
in NAP-216; daemon needs the same so it can talk to a TLS-terminated
registry like maestro-registry.n62.casa.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`configure::run` printed the "wrote" line whenever stdin was a TTY,
and `main::configure` printed it again unconditionally — so an
interactive run printed it twice. Library now stays silent; the binary
is the single owner of that confirmation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes NAP-224. Daemon onboarding now mirrors the registry's NAP-221 /
NAP-222 ergonomics: a fresh host needs three values, not eight.
Config (`crates/maestro-daemon/src/config.rs`):
- Split into `DaemonConfigFile` (on-disk, every field `Option<>`) and
`DaemonConfig` (resolved, all fields present).
- `load_config(path, EnvOverrides)` resolves env > file > default and
returns clear `Missing { field, env, path }` errors for the three
fields that genuinely require a human: `registry_url`, `daemon_url`,
`token`.
- Defaults: `bind_addr = 127.0.0.1:7070`; `state_dir` /
`worktree_root` from XDG; `hostname` from `gethostname(2)`;
`daemon_id` defaults to `hostname`.
- Treats a missing config file as empty so an env-only deploy works.
- Atomic file save (tmp + rename) for `configure` to share.
Env vars (mirrors NAP-222):
- `MAESTRO_TOKEN`
- `MAESTROD_REGISTRY_URL`, `MAESTROD_BIND_ADDR`,
`MAESTROD_DAEMON_URL`, `MAESTROD_DAEMON_ID`
- `MAESTROD_CONFIG` for the file path
`maestrod configure` (`configure.rs` + clap in `main.rs`):
- Interactive bootstrap when stdin is a TTY: prompts for the three
required fields, defaults the rest.
- Non-interactive mode (`--non-interactive` or non-TTY stdin) takes
values from flags only; errors out listing the first missing one.
- Refuses to overwrite an existing config without `--force`.
- Writes via the same atomic-save path used by config-file edits.
`maestrod` grew a clap CLI: `run` (default) and `configure` subcommands,
both honour `--config` / `$MAESTROD_CONFIG`.
Verified end-to-end:
- `maestrod configure --non-interactive --registry-url ... --daemon-url
... --token ...` writes a valid file and exits 0.
- `--non-interactive` without all required flags errors with the
missing field name.
- Existing file + no `--force` is rejected and the file is untouched.
- Workspace `cargo test`: 115 passed (was 97; +18 new daemon tests
across config + configure).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NAP-218. Add a node:20 stage to the registry Dockerfile that runs
`pnpm install && pnpm build` against `web/`, then copies the
resulting `dist/` into the Rust builder so rust-embed picks it up.
Single-binary deploy still — no separate static-asset volume.
`.dockerignore` keeps `web/node_modules` and `web/dist` out of the
build context (the node stage produces them fresh) while letting the
sources through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NAP-218. The registry's `GET /` now serves the built web bundle from
`web/dist/` (embedded into the binary at compile time via rust-embed)
instead of the NAP-215 placeholder, and `GET /assets/*` serves the
hashed JS/CSS chunks Vite produces.
When `web/dist` is empty (fresh checkout without `pnpm build`), a
small fallback page tells the operator to run the JS build. That
keeps `cargo run -p maestro-registry` usable without a node
toolchain installed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NAP-218. Browser UI for the prototype: catalog tree derived from
`metadata.label`, live updates over `WS /api/events`, search across
session name+label, spawn modal (with optional git worktree), and an
attach view that opens the daemon's per-session WS for xterm.js.
Framework choice: vanilla TypeScript. The interactivity surface is
small enough that pulling in a framework wouldn't pay back; xterm.js
does the heavy lifting. Easy to swap later without protocol impact.
Hash-based routing (`#/`, `#/attach/<id>`) keeps the registry's
static surface dumb — no SPA fallback needed; concurrent attaches
work by opening the same hash URL in another tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes NAP-216. Thin client for the registry HTTP API + per-session
daemon WebSocket. Becomes the headless test harness for NAP-217 and
unblocks the demo-by-script path before the web client lands (NAP-218).
Crate layout (`crates/maestro-cli/`):
- `cli.rs` — clap-derive parser. Commands: `list`, `spawn`, `attach`,
`kill`, `set-registry`. Global `--registry-url` (env
`MAESTRO_REGISTRY_URL`), `--config`, repeatable `-v`.
- `config.rs` — `CliConfig` persisted at `~/.config/maestro/cli.toml`,
storing the default registry URL + the session cookie issued by the
registry's cookie middleware.
- `http.rs` — `RegistryClient` wrapping `reqwest`. Attaches the
persisted cookie, captures `Set-Cookie` responses (first-contact
bootstrap and rotation), atomically writes the new value back to
the config file.
- `sessions.rs` — `list/spawn/kill` + connect-info fetch. `list` prints
a per-daemon grouped table; `--json` for scripting.
- `attach.rs` — raw-TTY WS proxy. Termios cfmakeraw with an RAII guard
for restore-on-drop, SIGWINCH → `ClientControl::Resize` text frame,
configurable detach key (default Ctrl-q; accepts `^X` form), echoes
`Ping`/`Pong` for keepalive symmetry with the daemon side.
Workspace deps added: clap (derive+env), reqwest (rustls+cookies),
nix (term+signal).
Smoke-tested against the live registry at maestro-registry.n62.casa:
`maestro list` issued + persisted the cookie and printed the empty
catalog cleanly. 19 new unit tests (parser, config round-trip,
table-printing, cookie extraction, attach URL/detach-key parsing).
Full workspace `cargo test`: 97 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-stage build (rust:1-bookworm builder → debian:bookworm-slim
runtime) for the `maestro-registry` binary. Uses BuildKit cargo cache
mounts so subsequent rebuilds are fast.
Defaults `MAESTRO_REGISTRY_BIND_ADDR=0.0.0.0:8080` so the container
is reachable from a reverse proxy (Traefik, etc.) without any extra
config. Operator supplies `MAESTRO_TOKEN` as a platform secret; with
that set the binary needs no writable volume.
`.dockerignore` keeps `target/`, `worktrees/`, `.git/`, docs, and the
WIP `web/` out of the build context.
Verified: docker build succeeds; `docker run -e MAESTRO_TOKEN=...
-p 8080:8080 maestro-registry` boots and responds 200 on the cookie
auth endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets `maestro-registry` run in container deployments (e.g. Dokploy)
without mounting a writable config volume.
- `MAESTRO_TOKEN`: when set, bypasses `registry.toml` entirely (no
read, no write). The container needs no persistent volume just to
seed the shared secret — pass it as a platform secret instead.
- `MAESTRO_REGISTRY_BIND_ADDR`: overrides the file's `bind_addr`.
Lets the container bind `0.0.0.0:$PORT` without rewriting TOML.
- New `load_config(path, env_token, env_bind_addr)` in `token.rs`
centralises the env > file > default precedence. Takes explicit
`Option`s so tests don't touch process-global env.
- `TokenError::InvalidBindAddr` carries the raw input + parse error
so misconfigured deploys log a useful diagnostic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes NAP-221. Adds the `maestro-registry` bin so the registry can be
spawned as a child process (required by the NAP-217 E2E harness) and
run as a long-lived service.
- New `crates/maestro-registry/src/main.rs` with a `#[tokio::main]`
entry point: loads config via `ensure_token` (override path via
`MAESTRO_REGISTRY_CONFIG`), binds the configured `bind_addr`, serves
the production router, and shuts down cleanly on Ctrl-C. Mirrors the
`maestrod` wiring (EnvFilter tracing, ExitCode reporting).
- `[lib]` + `[[bin]]` sections in `crates/maestro-registry/Cargo.toml`
so the crate exposes both surfaces (same pattern as `maestro-daemon`).
- `RegistryConfig` gains a `bind_addr: SocketAddr` field with
`serde(default)` to default to `127.0.0.1:8080` per spec §Registry.
Existing TOML files without the field still load.
- `default_config_path` re-exported from the crate root for the bin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the client-facing surface on the registry (NAP-215). Endpoints:
- GET /api/daemons — list connected daemons (with daemon_url)
- GET /api/sessions — flat session catalog
- POST /api/sessions — forwards CreateSession to chosen daemon, 504 on
10s daemon timeout
- DELETE /api/sessions/:id — forwards KillSession
- GET /api/sessions/:id/connect-info — returns {daemon_url, jwt} with a
5min HS256-signed attach JWT
- WS /api/events — snapshot + delta broadcast of catalog and presence
- GET / placeholder served until NAP-218 web bundle lands
Cookie auth middleware mints a long-lived httpOnly JWT cookie on first
request and reissues silently on missing/tampered cookies. Per spec the
perimeter is the operator's network — the cookie is a second factor.
Protocol additions: daemon_url on Hello, DaemonInfo, ConnectInfo,
RegistryEvent. Daemon config + RegistryClient now carry daemon_url.
Registry state now exposes an event broadcast channel (subscribe gives
a snapshot + tokio::sync::broadcast::Receiver) and helper lookups
daemon_for_session / daemon_url.
Tests: 11 reqwest-driven HTTP integration tests cover every endpoint,
the cookie flow, 504 timeout path, worktree forwarding, and events WS
propagation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tokio::fs::File` routes every `.read()` through `spawn_blocking`. On
an idle FIFO opened r+w, that thread blocks in `read()` forever — and
when the test runtime tries to shut down, the blocking-pool thread is
stuck on the syscall and the test binary hangs (which is exactly what
just took out CI run 11).
Switch to `std::fs::File` opened `O_RDWR | O_NONBLOCK` wrapped in
`tokio::io::unix::AsyncFd`. Reads now drive off epoll readiness with
`try_io`; no blocking-pool threads, no hung shutdown.
`Drop` still aborts the task so per-test cleanup is clean.